Skip to content

API Log Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add input/output visibility by logging all API traffic and displaying it in the admin UI with per-entity timelines.

Architecture: A kernel event subscriber captures all /api/v1/* request/response pairs and persists them to an ApiLog entity. The admin UI provides both a dedicated log browser and per-entity timeline tabs on product/partner/sale-order detail pages. A console command handles 30-day retention.

Tech Stack: Symfony 8.0, PHP 8.4, Doctrine ORM, Twig templates, PHPUnit


Task 1: ApiLogDirection Enum

Files: - Create: src/Enum/ApiLogDirection.php - Test: tests/Unit/Enum/ApiLogDirectionTest.php

Step 1: Write the failing test

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Enum;

use App\Enum\ApiLogDirection;
use PHPUnit\Framework\TestCase;

class ApiLogDirectionTest extends TestCase
{
    public function testEnumValues(): void
    {
        self::assertSame('inbound', ApiLogDirection::INBOUND->value);
        self::assertSame('outbound', ApiLogDirection::OUTBOUND->value);
    }

    public function testFromString(): void
    {
        self::assertSame(ApiLogDirection::INBOUND, ApiLogDirection::from('inbound'));
        self::assertSame(ApiLogDirection::OUTBOUND, ApiLogDirection::from('outbound'));
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Unit/Enum/ApiLogDirectionTest.php Expected: FAIL - class not found

Step 3: Write the enum

<?php

declare(strict_types=1);

namespace App\Enum;

enum ApiLogDirection: string
{
    case INBOUND = 'inbound';
    case OUTBOUND = 'outbound';
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Unit/Enum/ApiLogDirectionTest.php Expected: PASS

Step 5: Commit

git add src/Enum/ApiLogDirection.php tests/Unit/Enum/ApiLogDirectionTest.php
git commit -m "Add ApiLogDirection enum"

Task 2: ApiLog Entity

Files: - Create: src/Entity/ApiLog.php - Test: tests/Unit/Entity/ApiLogTest.php

Step 1: Write the failing test

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Entity;

use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use PHPUnit\Framework\TestCase;

class ApiLogTest extends TestCase
{
    public function testNewApiLogHasCorrectDefaults(): void
    {
        $log = new ApiLog();

        self::assertNull($log->getId());
        self::assertNull($log->getMethod());
        self::assertNull($log->getPath());
        self::assertNull($log->getEntityType());
        self::assertNull($log->getExternalId());
        self::assertNull($log->getRequestBody());
        self::assertNull($log->getResponseBody());
        self::assertNull($log->getDuration());
        self::assertNull($log->getCreatedAt());
    }

    public function testSettersReturnStaticForFluency(): void
    {
        $log = new ApiLog();

        $result = $log->setMethod('POST');
        self::assertSame($log, $result);

        $result = $log->setPath('/api/v1/products');
        self::assertSame($log, $result);
    }

    public function testDirectionGetterReturnsEnum(): void
    {
        $log = new ApiLog();
        $log->setDirection(ApiLogDirection::INBOUND);

        self::assertSame(ApiLogDirection::INBOUND, $log->getDirection());
    }

    public function testSourceGetterSetter(): void
    {
        $log = new ApiLog();
        $log->setSource('odoo');

        self::assertSame('odoo', $log->getSource());
    }

    public function testAllFieldsRoundTrip(): void
    {
        $log = new ApiLog();
        $log->setMethod('POST')
            ->setPath('/api/v1/products')
            ->setDirection(ApiLogDirection::INBOUND)
            ->setSource('odoo')
            ->setEntityType('product')
            ->setExternalId('PROD-001')
            ->setRequestBody('{"external_id":"PROD-001"}')
            ->setResponseBody('{"status":"ok"}')
            ->setStatusCode(200)
            ->setIpAddress('192.168.1.1')
            ->setDuration(42);

        self::assertSame('POST', $log->getMethod());
        self::assertSame('/api/v1/products', $log->getPath());
        self::assertSame(ApiLogDirection::INBOUND, $log->getDirection());
        self::assertSame('odoo', $log->getSource());
        self::assertSame('product', $log->getEntityType());
        self::assertSame('PROD-001', $log->getExternalId());
        self::assertSame('{"external_id":"PROD-001"}', $log->getRequestBody());
        self::assertSame('{"status":"ok"}', $log->getResponseBody());
        self::assertSame(200, $log->getStatusCode());
        self::assertSame('192.168.1.1', $log->getIpAddress());
        self::assertSame(42, $log->getDuration());
    }

    public function testPrePersistSetsCreatedAt(): void
    {
        $log = new ApiLog();
        self::assertNull($log->getCreatedAt());

        $log->setCreatedAtValue();
        self::assertInstanceOf(\DateTimeImmutable::class, $log->getCreatedAt());
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Unit/Entity/ApiLogTest.php Expected: FAIL - class not found

Step 3: Write the entity

<?php

declare(strict_types=1);

namespace App\Entity;

use App\Enum\ApiLogDirection;
use App\Repository\ApiLogRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ApiLogRepository::class)]
#[ORM\Table(name: 'api_log')]
#[ORM\Index(columns: ['entity_type', 'external_id'], name: 'idx_api_log_entity')]
#[ORM\Index(columns: ['created_at'], name: 'idx_api_log_created_at')]
#[ORM\Index(columns: ['direction'], name: 'idx_api_log_direction')]
#[ORM\HasLifecycleCallbacks]
class ApiLog
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 10)]
    private ?string $method = null;

    #[ORM\Column(length: 500)]
    private ?string $path = null;

    #[ORM\Column(length: 20)]
    private string $direction = ApiLogDirection::INBOUND->value;

    #[ORM\Column(length: 20)]
    private ?string $source = null;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $entityType = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $externalId = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $requestBody = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $responseBody = null;

    #[ORM\Column(type: Types::SMALLINT)]
    private int $statusCode = 0;

    #[ORM\Column(length: 45)]
    private ?string $ipAddress = null;

    #[ORM\Column(nullable: true)]
    private ?int $duration = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getMethod(): ?string
    {
        return $this->method;
    }

    public function setMethod(string $method): static
    {
        $this->method = $method;

        return $this;
    }

    public function getPath(): ?string
    {
        return $this->path;
    }

    public function setPath(string $path): static
    {
        $this->path = $path;

        return $this;
    }

    public function getDirection(): ApiLogDirection
    {
        return ApiLogDirection::from($this->direction);
    }

    public function setDirection(ApiLogDirection $direction): static
    {
        $this->direction = $direction->value;

        return $this;
    }

    public function getSource(): ?string
    {
        return $this->source;
    }

    public function setSource(string $source): static
    {
        $this->source = $source;

        return $this;
    }

    public function getEntityType(): ?string
    {
        return $this->entityType;
    }

    public function setEntityType(?string $entityType): static
    {
        $this->entityType = $entityType;

        return $this;
    }

    public function getExternalId(): ?string
    {
        return $this->externalId;
    }

    public function setExternalId(?string $externalId): static
    {
        $this->externalId = $externalId;

        return $this;
    }

    public function getRequestBody(): ?string
    {
        return $this->requestBody;
    }

    public function setRequestBody(?string $requestBody): static
    {
        $this->requestBody = $requestBody;

        return $this;
    }

    public function getResponseBody(): ?string
    {
        return $this->responseBody;
    }

    public function setResponseBody(?string $responseBody): static
    {
        $this->responseBody = $responseBody;

        return $this;
    }

    public function getStatusCode(): int
    {
        return $this->statusCode;
    }

    public function setStatusCode(int $statusCode): static
    {
        $this->statusCode = $statusCode;

        return $this;
    }

    public function getIpAddress(): ?string
    {
        return $this->ipAddress;
    }

    public function setIpAddress(string $ipAddress): static
    {
        $this->ipAddress = $ipAddress;

        return $this;
    }

    public function getDuration(): ?int
    {
        return $this->duration;
    }

    public function setDuration(?int $duration): static
    {
        $this->duration = $duration;

        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    #[ORM\PrePersist]
    public function setCreatedAtValue(): void
    {
        $this->createdAt = new \DateTimeImmutable();
    }
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Unit/Entity/ApiLogTest.php Expected: PASS

Step 5: Commit

git add src/Entity/ApiLog.php tests/Unit/Entity/ApiLogTest.php
git commit -m "Add ApiLog entity"

Task 3: ApiLogRepository

Files: - Create: src/Repository/ApiLogRepository.php - Test: tests/Unit/Repository/ApiLogRepositoryTest.php

Step 1: Write the failing test

Note: Unit tests for repositories are limited since most methods need a real database. We test the class structure and any pure methods. Integration tests for query methods will be covered by the controller tests in Task 7.

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Repository;

use App\Entity\ApiLog;
use App\Repository\ApiLogRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\TestCase;

class ApiLogRepositoryTest extends TestCase
{
    public function testRepositoryCanBeInstantiated(): void
    {
        $registry = $this->createMock(ManagerRegistry::class);
        $em = $this->createMock(EntityManagerInterface::class);
        $metadata = new ClassMetadata(ApiLog::class);

        $registry->method('getManagerForClass')->willReturn($em);
        $em->method('getClassMetadata')->willReturn($metadata);

        $repository = new ApiLogRepository($registry);

        self::assertInstanceOf(ApiLogRepository::class, $repository);
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Unit/Repository/ApiLogRepositoryTest.php Expected: FAIL - class not found

Step 3: Write the repository

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\ApiLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<ApiLog>
 */
class ApiLogRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, ApiLog::class);
    }

    public function save(ApiLog $apiLog, bool $flush = true): void
    {
        $this->getEntityManager()->persist($apiLog);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    /**
     * @return ApiLog[]
     */
    public function findByEntity(string $entityType, string $externalId, int $limit = 50): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.entityType = :entityType')
            ->andWhere('a.externalId = :externalId')
            ->setParameter('entityType', $entityType)
            ->setParameter('externalId', $externalId)
            ->orderBy('a.createdAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }

    public function deleteOlderThan(\DateTimeImmutable $cutoff): int
    {
        return $this->createQueryBuilder('a')
            ->delete()
            ->where('a.createdAt < :cutoff')
            ->setParameter('cutoff', $cutoff)
            ->getQuery()
            ->execute();
    }
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Unit/Repository/ApiLogRepositoryTest.php Expected: PASS

Step 5: Commit

git add src/Repository/ApiLogRepository.php tests/Unit/Repository/ApiLogRepositoryTest.php
git commit -m "Add ApiLogRepository with entity query and purge methods"

Task 4: Database Migration

Files: - Create: migrations/Version<timestamp>.php (generated by Doctrine)

Step 1: Generate the migration

Run: php -d variables_order=EGPCS bin/console doctrine:migrations:diff

This will generate a migration file creating the api_log table.

Step 2: Review the generated migration

Verify it creates the api_log table with all columns and indexes. The down() method should drop the table.

Step 3: Run the migration

Run: php -d variables_order=EGPCS bin/console doctrine:migrations:migrate Expected: Migration applied successfully

Step 4: Commit

git add migrations/
git commit -m "Add api_log database migration"

Task 5: ApiLogSubscriber (Capture Mechanism)

Files: - Create: src/EventSubscriber/ApiLogSubscriber.php - Test: tests/Unit/EventSubscriber/ApiLogSubscriberTest.php

Step 1: Write the failing test

<?php

declare(strict_types=1);

namespace App\Tests\Unit\EventSubscriber;

use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use App\EventSubscriber\ApiLogSubscriber;
use App\Repository\ApiLogRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;

class ApiLogSubscriberTest extends TestCase
{
    private ApiLogRepository&MockObject $apiLogRepository;
    private ApiLogSubscriber $subscriber;
    private HttpKernelInterface&MockObject $kernel;

    protected function setUp(): void
    {
        $this->apiLogRepository = $this->createMock(ApiLogRepository::class);
        $this->subscriber = new ApiLogSubscriber($this->apiLogRepository);
        $this->kernel = $this->createMock(HttpKernelInterface::class);
    }

    public function testGetSubscribedEvents(): void
    {
        $events = ApiLogSubscriber::getSubscribedEvents();

        self::assertArrayHasKey(KernelEvents::REQUEST, $events);
        self::assertArrayHasKey(KernelEvents::RESPONSE, $events);
    }

    public function testIgnoresNonApiRoutes(): void
    {
        $request = Request::create('/admin/products', 'GET');
        $event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);

        $this->subscriber->onKernelRequest($event);

        // No attribute set means it won't be logged
        self::assertFalse($request->attributes->has('_api_log_start'));
    }

    public function testCapturesApiV1Routes(): void
    {
        $request = Request::create('/api/v1/products', 'POST', [], [], [], [], '{"external_id":"PROD-001"}');
        $event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);

        $this->subscriber->onKernelRequest($event);

        self::assertTrue($request->attributes->has('_api_log_start'));
    }

    public function testInboundPostProductDetectsDirectionAndEntityType(): void
    {
        $request = Request::create('/api/v1/products', 'POST', [], [], [], [], '{"external_id":"PROD-001"}');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('{"status":"ok"}', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertSame('POST', $savedLog->getMethod());
        self::assertSame('/api/v1/products', $savedLog->getPath());
        self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
        self::assertSame('odoo', $savedLog->getSource());
        self::assertSame('product', $savedLog->getEntityType());
        self::assertSame('PROD-001', $savedLog->getExternalId());
        self::assertSame('{"external_id":"PROD-001"}', $savedLog->getRequestBody());
        self::assertSame('{"status":"ok"}', $savedLog->getResponseBody());
        self::assertSame(200, $savedLog->getStatusCode());
    }

    public function testOutboundGetUpdatedProductsDetectsDirection(): void
    {
        $request = Request::create('/api/v1/updated-products', 'GET');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('[]', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertSame(ApiLogDirection::OUTBOUND, $savedLog->getDirection());
        self::assertSame('webshop', $savedLog->getSource());
        self::assertSame('product', $savedLog->getEntityType());
    }

    public function testOutboundGetSingleUpdatedProductExtractsExternalId(): void
    {
        $request = Request::create('/api/v1/updated-products/PROD-001', 'GET');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('{}', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertSame('PROD-001', $savedLog->getExternalId());
    }

    public function testInboundFailureFromWebshop(): void
    {
        $request = Request::create('/api/v1/failures', 'POST', [], [], [], [], '{"entity_type":"product","external_id":"PROD-001"}');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('{}', 201);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
        self::assertSame('webshop', $savedLog->getSource());
    }

    public function testAcknowledgementFromWebshop(): void
    {
        $request = Request::create('/api/v1/updates/product/PROD-001/ack', 'POST');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('{}', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
        self::assertSame('webshop', $savedLog->getSource());
        self::assertSame('product', $savedLog->getEntityType());
        self::assertSame('PROD-001', $savedLog->getExternalId());
    }

    public function testInboundStocksDetectsCorrectly(): void
    {
        $request = Request::create('/api/v1/stocks', 'POST', [], [], [], [], '{"items":[]}');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('{}', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertSame(ApiLogDirection::INBOUND, $savedLog->getDirection());
        self::assertSame('odoo', $savedLog->getSource());
        self::assertSame('stock', $savedLog->getEntityType());
    }

    public function testOutboundStocksGetDetectsCorrectly(): void
    {
        $request = Request::create('/api/v1/stocks', 'GET');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('[]', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertSame(ApiLogDirection::OUTBOUND, $savedLog->getDirection());
        self::assertSame('webshop', $savedLog->getSource());
    }

    public function testIgnoresSubRequests(): void
    {
        $request = Request::create('/api/v1/products', 'POST');
        $event = new RequestEvent($this->kernel, $request, HttpKernelInterface::SUB_REQUEST);

        $this->subscriber->onKernelRequest($event);

        self::assertFalse($request->attributes->has('_api_log_start'));
    }

    public function testResponseWithoutRequestMarkIsIgnored(): void
    {
        $request = Request::create('/admin/products', 'GET');
        $response = new Response('', 200);
        $event = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $this->apiLogRepository->expects(self::never())->method('save');

        $this->subscriber->onKernelResponse($event);
    }

    public function testBatchPostHasNullExternalId(): void
    {
        $body = json_encode([
            ['external_id' => 'PROD-001', 'name' => 'A'],
            ['external_id' => 'PROD-002', 'name' => 'B'],
        ]);
        $request = Request::create('/api/v1/products', 'POST', [], [], [], [], $body);
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('{}', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertNull($savedLog->getExternalId());
    }

    public function testDurationIsCalculated(): void
    {
        $request = Request::create('/api/v1/products', 'POST', [], [], [], [], '{}');
        $requestEvent = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
        $this->subscriber->onKernelRequest($requestEvent);

        $response = new Response('{}', 200);
        $responseEvent = new ResponseEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);

        $savedLog = null;
        $this->apiLogRepository->expects(self::once())
            ->method('save')
            ->with(self::callback(function (ApiLog $log) use (&$savedLog): bool {
                $savedLog = $log;

                return true;
            }));

        $this->subscriber->onKernelResponse($responseEvent);

        self::assertNotNull($savedLog->getDuration());
        self::assertGreaterThanOrEqual(0, $savedLog->getDuration());
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Unit/EventSubscriber/ApiLogSubscriberTest.php Expected: FAIL - class not found

Step 3: Write the subscriber

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use App\Repository\ApiLogRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class ApiLogSubscriber implements EventSubscriberInterface
{
    private const API_PREFIX = '/api/v1/';

    /**
     * Path patterns that indicate inbound traffic from the webshop (not Odoo).
     */
    private const WEBSHOP_INBOUND_PATTERNS = [
        '/api/v1/failures',
        '/api/v1/updates/',
    ];

    /**
     * Maps path segments to entity types.
     */
    private const PATH_TO_ENTITY_TYPE = [
        'products' => 'product',
        'updated-products' => 'product',
        'partners' => 'partner',
        'updated-partners' => 'partner',
        'sale-orders' => 'sale_order',
        'updated-sale-orders' => 'sale_order',
        'stocks' => 'stock',
        'pricelists' => 'pricelist',
        'purchase-orders' => 'purchase_order',
        'outbound-queue' => 'outbound_queue',
        'failures' => 'failure',
    ];

    public function __construct(
        private readonly ApiLogRepository $apiLogRepository,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onKernelRequest', 0],
            KernelEvents::RESPONSE => ['onKernelResponse', 0],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();
        $path = $request->getPathInfo();

        if (!str_starts_with($path, self::API_PREFIX)) {
            return;
        }

        $request->attributes->set('_api_log_start', microtime(true));
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();

        if (!$request->attributes->has('_api_log_start')) {
            return;
        }

        $startTime = $request->attributes->get('_api_log_start');
        $response = $event->getResponse();
        $path = $request->getPathInfo();
        $method = $request->getMethod();

        $direction = $this->determineDirection($method, $path);
        $source = $this->determineSource($direction, $path);
        $entityType = $this->extractEntityType($path);
        $externalId = $this->extractExternalId($method, $path, $request->getContent());

        $duration = (int) round((microtime(true) - $startTime) * 1000);

        $log = new ApiLog();
        $log->setMethod($method)
            ->setPath($path)
            ->setDirection($direction)
            ->setSource($source)
            ->setEntityType($entityType)
            ->setExternalId($externalId)
            ->setRequestBody($request->getContent() ?: null)
            ->setResponseBody($response->getContent() ?: null)
            ->setStatusCode($response->getStatusCode())
            ->setIpAddress($request->getClientIp() ?? '0.0.0.0')
            ->setDuration($duration);

        $this->apiLogRepository->save($log);
    }

    private function determineDirection(string $method, string $path): ApiLogDirection
    {
        // GET requests to API are always outbound (webshop fetching data)
        if ('GET' === $method || 'DELETE' === $method) {
            return ApiLogDirection::OUTBOUND;
        }

        // POST to outbound-queue is outbound
        if (str_contains($path, '/outbound-queue')) {
            return ApiLogDirection::OUTBOUND;
        }

        // All other POST requests are inbound
        return ApiLogDirection::INBOUND;
    }

    private function determineSource(ApiLogDirection $direction, string $path): string
    {
        if (ApiLogDirection::OUTBOUND === $direction) {
            // Outbound queue operations are toward Odoo
            if (str_contains($path, '/outbound-queue')) {
                return 'odoo';
            }

            return 'webshop';
        }

        // Inbound: failures and acks come from webshop
        foreach (self::WEBSHOP_INBOUND_PATTERNS as $pattern) {
            if (str_starts_with($path, $pattern)) {
                return 'webshop';
            }
        }

        return 'odoo';
    }

    private function extractEntityType(string $path): ?string
    {
        // Remove /api/v1/ prefix
        $relativePath = substr($path, \strlen(self::API_PREFIX));
        // Get the first path segment
        $segment = explode('/', $relativePath)[0];

        // Handle /updates/{entityType}/{id}/ack
        if ('updates' === $segment) {
            $parts = explode('/', $relativePath);

            return $parts[1] ?? null;
        }

        return self::PATH_TO_ENTITY_TYPE[$segment] ?? null;
    }

    private function extractExternalId(string $method, string $path, string $body): ?string
    {
        // Try to extract from /updates/{entityType}/{externalId}/ack
        if (preg_match('#/api/v1/updates/[^/]+/([^/]+)/ack#', $path, $matches)) {
            return $matches[1];
        }

        // Try to extract from /updated-{entity}/{externalId}
        if (preg_match('#/api/v1/updated-[^/]+/([^/?]+)#', $path, $matches)) {
            return $matches[1];
        }

        // For POST requests, try to extract from body
        if ('POST' === $method && '' !== $body) {
            $data = json_decode($body, true);

            if (\is_array($data) && isset($data['external_id']) && \is_string($data['external_id'])) {
                return $data['external_id'];
            }

            // If the body is a JSON array (batch), don't extract a single external_id
        }

        return null;
    }
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Unit/EventSubscriber/ApiLogSubscriberTest.php Expected: PASS

Step 5: Run full test suite

Run: bin/phpunit Expected: All tests pass (subscriber doesn't break existing tests)

Step 6: Commit

git add src/EventSubscriber/ApiLogSubscriber.php tests/Unit/EventSubscriber/ApiLogSubscriberTest.php
git commit -m "Add ApiLogSubscriber to capture API traffic"

Task 6: Admin ApiLogController

Files: - Create: src/Controller/Admin/ApiLogController.php - Test: tests/Controller/Admin/ApiLogControllerTest.php

Step 1: Write the failing test

<?php

declare(strict_types=1);

namespace App\Tests\Controller\Admin;

use App\Entity\ApiLog;
use App\Entity\User;
use App\Enum\ApiLogDirection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ApiLogControllerTest extends WebTestCase
{
    private KernelBrowser $client;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->client = static::createClient();
        $this->em = static::getContainer()->get(EntityManagerInterface::class);
        $this->loginAsAdmin();
    }

    public function testIndexPageIsAccessible(): void
    {
        $this->createApiLog('/api/v1/products', 'POST', ApiLogDirection::INBOUND, 'odoo', 200);

        $this->client->request('GET', '/admin/api-logs');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('body', '/api/v1/products');
    }

    public function testIndexFiltersByDirection(): void
    {
        $this->createApiLog('/api/v1/products', 'POST', ApiLogDirection::INBOUND, 'odoo', 200);
        $this->createApiLog('/api/v1/updated-products', 'GET', ApiLogDirection::OUTBOUND, 'webshop', 200);

        $this->client->request('GET', '/admin/api-logs?direction=inbound');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('body', 'POST');
    }

    public function testShowPageDisplaysLogDetails(): void
    {
        $log = $this->createApiLog('/api/v1/products', 'POST', ApiLogDirection::INBOUND, 'odoo', 200);

        $this->client->request('GET', '/admin/api-logs/'.$log->getId());

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('body', '/api/v1/products');
        $this->assertSelectorTextContains('body', 'POST');
    }

    public function testShowPageDisplaysJsonBodies(): void
    {
        $log = $this->createApiLog(
            '/api/v1/products',
            'POST',
            ApiLogDirection::INBOUND,
            'odoo',
            200,
            '{"external_id":"PROD-001"}',
            '{"status":"ok"}'
        );

        $this->client->request('GET', '/admin/api-logs/'.$log->getId());

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('body', 'PROD-001');
    }

    public function testIndexRequiresAuthentication(): void
    {
        $client = static::createClient();
        $client->request('GET', '/admin/api-logs');

        $this->assertResponseRedirects();
    }

    private function loginAsAdmin(): void
    {
        $userRepository = $this->em->getRepository(User::class);
        $admin = $userRepository->findOneBy([]);

        if (!$admin) {
            $admin = new User();
            $admin->setEmail('admin@test.com');
            $admin->setPassword('$2y$13$dummy');
            $admin->setRoles(['ROLE_ADMIN']);
            $this->em->persist($admin);
            $this->em->flush();
        }

        $this->client->loginUser($admin);
    }

    private function createApiLog(
        string $path,
        string $method,
        ApiLogDirection $direction,
        string $source,
        int $statusCode,
        ?string $requestBody = null,
        ?string $responseBody = null,
    ): ApiLog {
        $log = new ApiLog();
        $log->setMethod($method)
            ->setPath($path)
            ->setDirection($direction)
            ->setSource($source)
            ->setStatusCode($statusCode)
            ->setIpAddress('127.0.0.1')
            ->setRequestBody($requestBody)
            ->setResponseBody($responseBody);

        $this->em->persist($log);
        $this->em->flush();

        return $log;
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Controller/Admin/ApiLogControllerTest.php Expected: FAIL - 404 (route not found)

Step 3: Write the controller

<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;
use App\Repository\ApiLogRepository;
use App\Service\Admin\AdminPaginationService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

/**
 * @extends AbstractAdminController<ApiLog>
 */
#[IsGranted('ROLE_ADMIN')]
#[Route('/admin/api-logs', name: 'admin_api_logs')]
class ApiLogController extends AbstractAdminController
{
    public function __construct(
        EntityManagerInterface $entityManager,
        AdminPaginationService $paginationService,
        private readonly ApiLogRepository $apiLogRepository,
    ) {
        parent::__construct($entityManager, $paginationService);
    }

    protected function getEntityClass(): string
    {
        return ApiLog::class;
    }

    protected function getFormType(): ?string
    {
        return null;
    }

    protected function getEntityLabel(): string
    {
        return 'API Log';
    }

    protected function getRoutePrefix(): string
    {
        return 'admin_api_logs';
    }

    protected function getTemplateDirectory(): string
    {
        return 'admin/api_logs';
    }

    #[Route('', name: '', methods: ['GET'])]
    #[Route('', name: '_index', methods: ['GET'])]
    public function index(Request $request): Response
    {
        $direction = $request->query->get('direction');
        $source = $request->query->get('source');
        $entityType = $request->query->get('entity_type');

        $qb = $this->createEntityQueryBuilder('e');

        if ($direction && ApiLogDirection::tryFrom($direction)) {
            $qb->andWhere('e.direction = :direction')
               ->setParameter('direction', $direction);
        }

        if ($source) {
            $qb->andWhere('e.source = :source')
               ->setParameter('source', $source);
        }

        if ($entityType) {
            $qb->andWhere('e.entityType = :entityType')
               ->setParameter('entityType', $entityType);
        }

        $qb->orderBy('e.id', 'DESC');

        $result = $this->paginationService->paginate($qb, $request, 50);

        return $this->render($this->getTemplateDirectory().'/index.html.twig', [
            'result' => $result,
            'current_direction' => $direction,
            'current_source' => $source,
            'current_entity_type' => $entityType,
            'entity_label' => $this->getEntityLabel(),
            'route_prefix' => $this->getRoutePrefix(),
        ]);
    }

    #[Route('/{id}', name: '_show', requirements: ['id' => '\d+'], methods: ['GET'])]
    public function show(int $id): Response
    {
        $entity = $this->findEntityOr404($id);

        return $this->renderShow($entity);
    }
}

Step 4: Create the templates (see Task 7)

Defer template creation to next task. For now, create minimal templates so tests pass.

Step 5: Run test to verify it passes

Run: bin/phpunit tests/Controller/Admin/ApiLogControllerTest.php Expected: PASS

Step 6: Commit

git add src/Controller/Admin/ApiLogController.php tests/Controller/Admin/ApiLogControllerTest.php
git commit -m "Add admin ApiLogController with index and show"

Task 7: Admin Templates (Index + Show)

Files: - Create: templates/admin/api_logs/index.html.twig - Create: templates/admin/api_logs/show.html.twig - Modify: templates/admin/base.html.twig (add sidebar link)

Step 1: Create the index template

{% extends 'admin/base.html.twig' %}

{% import 'admin/components/pagination.html.twig' as pagination %}
{% import 'admin/components/flash.html.twig' as flash %}

{% block admin_title %}API Logs{% endblock %}

{% block admin_breadcrumb %}
    <span class="admin-breadcrumb-separator">/</span>
    <span>API Logs</span>
{% endblock %}

{% block admin_content %}
    {{ flash.render_flash_messages() }}

    <div class="admin-page-header">
        <h1 class="admin-page-title">{{ entity_label }}</h1>
    </div>

    {# Filters #}
    <div class="admin-card" style="margin-bottom: 20px;">
        <div class="admin-card-body" style="padding: 15px;">
            <form method="get" action="{{ path(route_prefix) }}" style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
                <select name="direction" style="padding: 6px 10px; border: 1px solid var(--admin-border-color); border-radius: 4px;">
                    <option value="">All Directions</option>
                    <option value="inbound" {{ current_direction == 'inbound' ? 'selected' : '' }}>Inbound</option>
                    <option value="outbound" {{ current_direction == 'outbound' ? 'selected' : '' }}>Outbound</option>
                </select>
                <select name="source" style="padding: 6px 10px; border: 1px solid var(--admin-border-color); border-radius: 4px;">
                    <option value="">All Sources</option>
                    <option value="odoo" {{ current_source == 'odoo' ? 'selected' : '' }}>Odoo</option>
                    <option value="webshop" {{ current_source == 'webshop' ? 'selected' : '' }}>Webshop</option>
                </select>
                <select name="entity_type" style="padding: 6px 10px; border: 1px solid var(--admin-border-color); border-radius: 4px;">
                    <option value="">All Entity Types</option>
                    <option value="product" {{ current_entity_type == 'product' ? 'selected' : '' }}>Product</option>
                    <option value="partner" {{ current_entity_type == 'partner' ? 'selected' : '' }}>Partner</option>
                    <option value="sale_order" {{ current_entity_type == 'sale_order' ? 'selected' : '' }}>Sale Order</option>
                    <option value="stock" {{ current_entity_type == 'stock' ? 'selected' : '' }}>Stock</option>
                    <option value="pricelist" {{ current_entity_type == 'pricelist' ? 'selected' : '' }}>Pricelist</option>
                    <option value="purchase_order" {{ current_entity_type == 'purchase_order' ? 'selected' : '' }}>Purchase Order</option>
                </select>
                <button type="submit" class="admin-btn admin-btn-primary admin-btn-sm">Filter</button>
                <a href="{{ path(route_prefix) }}" class="admin-btn admin-btn-secondary admin-btn-sm">Clear</a>
            </form>
        </div>
    </div>

    <div class="admin-card">
        <div class="admin-card-body" style="padding: 0; overflow-x: auto;">
            {% if result.items|length == 0 %}
                <div class="admin-empty-state" style="padding: 40px;">
                    <div class="admin-empty-state-icon">&#128220;</div>
                    <div class="admin-empty-state-title">No API logs found</div>
                    <p>No API log entries match your current filters.</p>
                </div>
            {% else %}
                <table class="admin-table">
                    <thead>
                        <tr>
                            <th>Time</th>
                            <th>Method</th>
                            <th>Path</th>
                            <th>Direction</th>
                            <th>Source</th>
                            <th>Entity Type</th>
                            <th>Status</th>
                            <th>Duration</th>
                            <th></th>
                        </tr>
                    </thead>
                    <tbody>
                        {% for log in result.items %}
                            <tr>
                                <td style="white-space: nowrap;">{{ log.createdAt ? log.createdAt|date('Y-m-d H:i:s') : '-' }}</td>
                                <td>
                                    <span class="admin-badge admin-badge-{{ log.method == 'GET' ? 'success' : (log.method == 'POST' ? 'info' : 'warning') }}">
                                        {{ log.method }}
                                    </span>
                                </td>
                                <td style="font-family: monospace; font-size: 13px;">{{ log.path }}</td>
                                <td>
                                    <span class="admin-badge admin-badge-{{ log.direction.value == 'inbound' ? 'info' : 'warning' }}">
                                        {{ log.direction.value == 'inbound' ? '&#8592; IN' : '&#8594; OUT' }}
                                    </span>
                                </td>
                                <td>{{ log.source }}</td>
                                <td>{{ log.entityType ?? '-' }}</td>
                                <td>
                                    <span class="admin-badge admin-badge-{{ log.statusCode < 300 ? 'success' : (log.statusCode < 500 ? 'warning' : 'danger') }}">
                                        {{ log.statusCode }}
                                    </span>
                                </td>
                                <td>{{ log.duration is not null ? log.duration ~ 'ms' : '-' }}</td>
                                <td>
                                    <a href="{{ path(route_prefix ~ '_show', {id: log.id}) }}" class="admin-btn admin-btn-sm admin-btn-secondary">View</a>
                                </td>
                            </tr>
                        {% endfor %}
                    </tbody>
                </table>
            {% endif %}
        </div>
    </div>

    {{ pagination.render_pagination(result, route_prefix ~ '_index', {direction: current_direction, source: current_source, entity_type: current_entity_type}) }}
{% endblock %}

Step 2: Create the show template

{% extends 'admin/base.html.twig' %}

{% import 'admin/components/flash.html.twig' as flash %}

{% block admin_title %}API Log #{{ entity.id }}{% endblock %}

{% block admin_breadcrumb %}
    <span class="admin-breadcrumb-separator">/</span>
    <a href="{{ path(route_prefix ~ '_index') }}">API Logs</a>
    <span class="admin-breadcrumb-separator">/</span>
    <span>#{{ entity.id }}</span>
{% endblock %}

{% block admin_actions %}
    <a href="{{ path(route_prefix ~ '_index') }}" class="admin-btn admin-btn-secondary">
        Back to List
    </a>
{% endblock %}

{% block admin_content %}
    {{ flash.render_flash_messages() }}

    <div class="admin-page-header">
        <h1 class="admin-page-title">API Log #{{ entity.id }}</h1>
    </div>

    <div class="admin-card">
        <div class="admin-card-header">
            <h3 class="admin-card-title">Request Details</h3>
        </div>
        <div class="admin-card-body">
            <table class="admin-table" style="max-width: 800px;">
                <tbody>
                    <tr>
                        <th style="width: 200px;">ID</th>
                        <td>{{ entity.id }}</td>
                    </tr>
                    <tr>
                        <th>Time</th>
                        <td>{{ entity.createdAt ? entity.createdAt|date('Y-m-d H:i:s') : '-' }}</td>
                    </tr>
                    <tr>
                        <th>Method</th>
                        <td>
                            <span class="admin-badge admin-badge-{{ entity.method == 'GET' ? 'success' : (entity.method == 'POST' ? 'info' : 'warning') }}">
                                {{ entity.method }}
                            </span>
                        </td>
                    </tr>
                    <tr>
                        <th>Path</th>
                        <td style="font-family: monospace;">{{ entity.path }}</td>
                    </tr>
                    <tr>
                        <th>Direction</th>
                        <td>
                            <span class="admin-badge admin-badge-{{ entity.direction.value == 'inbound' ? 'info' : 'warning' }}">
                                {{ entity.direction.value == 'inbound' ? '&#8592; Inbound' : '&#8594; Outbound' }}
                            </span>
                        </td>
                    </tr>
                    <tr>
                        <th>Source</th>
                        <td>{{ entity.source }}</td>
                    </tr>
                    <tr>
                        <th>Entity Type</th>
                        <td>{{ entity.entityType ?? '-' }}</td>
                    </tr>
                    <tr>
                        <th>External ID</th>
                        <td>{{ entity.externalId ?? '-' }}</td>
                    </tr>
                    <tr>
                        <th>Status Code</th>
                        <td>
                            <span class="admin-badge admin-badge-{{ entity.statusCode < 300 ? 'success' : (entity.statusCode < 500 ? 'warning' : 'danger') }}">
                                {{ entity.statusCode }}
                            </span>
                        </td>
                    </tr>
                    <tr>
                        <th>Duration</th>
                        <td>{{ entity.duration is not null ? entity.duration ~ ' ms' : '-' }}</td>
                    </tr>
                    <tr>
                        <th>IP Address</th>
                        <td>{{ entity.ipAddress }}</td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>

    {% if entity.requestBody %}
    <div class="admin-card">
        <div class="admin-card-header">
            <h3 class="admin-card-title">Request Body</h3>
        </div>
        <div class="admin-card-body">
            <pre style="background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-family: monospace; font-size: 13px; margin: 0;">{{ entity.requestBody|json_encode is not same as(false) ? (entity.requestBody|raw|json_encode(constant('JSON_PRETTY_PRINT'))|default(entity.requestBody)) : entity.requestBody }}</pre>
        </div>
    </div>
    {% endif %}

    {% if entity.responseBody %}
    <div class="admin-card">
        <div class="admin-card-header">
            <h3 class="admin-card-title">Response Body</h3>
        </div>
        <div class="admin-card-body">
            <pre style="background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-family: monospace; font-size: 13px; margin: 0;">{{ entity.responseBody|json_encode is not same as(false) ? (entity.responseBody|raw|json_encode(constant('JSON_PRETTY_PRINT'))|default(entity.responseBody)) : entity.responseBody }}</pre>
        </div>
    </div>
    {% endif %}
{% endblock %}

Note on JSON pretty-printing: The request/response bodies are stored as raw strings. To pretty-print them in Twig, we need a small trick. The simplest approach is to decode+encode in the template. A cleaner approach is a Twig extension, but for now we can use json_decode in a Twig extension or simply output the raw string (it's already JSON). The implementer should test whether entity.requestBody can be pretty-printed with a simple Twig filter or if a custom json_pretty Twig filter is needed. Simplest path: just output the raw string in the <pre> tag - it's readable as-is since it's JSON.

Simplified approach for the <pre> tags:

<pre style="background: #f8f9fa; padding: 15px; border-radius: 4px; overflow-x: auto; font-family: monospace; font-size: 13px; margin: 0;">{{ entity.requestBody }}</pre>

Step 3: Add sidebar link to base template

In templates/admin/base.html.twig, add the API Logs link in the "Sync & Webhooks" nav section (after Webhook Deliveries):

                <a href="{{ path('admin_api_logs') }}" class="admin-nav-link {{ app.request.attributes.get('_route') starts with 'admin_api_logs' ? 'active' : '' }}">
                    <span class="admin-nav-icon">&#128220;</span>
                    API Logs
                </a>

Add this line after the Webhook Deliveries link (around line 778 in templates/admin/base.html.twig).

Step 4: Run tests

Run: bin/phpunit tests/Controller/Admin/ApiLogControllerTest.php Expected: PASS

Step 5: Commit

git add templates/admin/api_logs/ templates/admin/base.html.twig
git commit -m "Add admin API log templates and sidebar link"

Task 8: Product API Log Tab

Files: - Modify: src/Controller/Admin/ProductController.php (add apiLogs action) - Modify: templates/admin/products/_tabs.html.twig (add API Log tab) - Create: templates/admin/products/api-logs.html.twig - Modify: tests/Controller/Admin/ProductDetailTabsTest.php (add test)

Step 1: Write the failing test

Add to tests/Controller/Admin/ProductDetailTabsTest.php:

public function testApiLogsTabDisplaysLogEntries(): void
{
    $product = $this->createProduct('PROD-LOG-001');
    $this->createApiLogForProduct('PROD-LOG-001');

    $this->client->request('GET', '/admin/products/'.$product->getId().'/api-logs');

    $this->assertResponseIsSuccessful();
    $this->assertSelectorTextContains('body', '/api/v1/products');
}

public function testApiLogsTabShowsEmptyStateWhenNoLogs(): void
{
    $product = $this->createProduct('PROD-LOG-002');

    $this->client->request('GET', '/admin/products/'.$product->getId().'/api-logs');

    $this->assertResponseIsSuccessful();
    $this->assertSelectorTextContains('body', 'No API log entries');
}

private function createApiLogForProduct(string $externalId): ApiLog
{
    $log = new ApiLog();
    $log->setMethod('POST')
        ->setPath('/api/v1/products')
        ->setDirection(ApiLogDirection::INBOUND)
        ->setSource('odoo')
        ->setEntityType('product')
        ->setExternalId($externalId)
        ->setStatusCode(200)
        ->setIpAddress('127.0.0.1');

    $this->em->persist($log);
    $this->em->flush();

    return $log;
}

Add required imports at top of test file:

use App\Entity\ApiLog;
use App\Enum\ApiLogDirection;

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Controller/Admin/ProductDetailTabsTest.php --filter testApiLogsTab Expected: FAIL - 404 (route not found)

Step 3: Add route to ProductController

Add to src/Controller/Admin/ProductController.php:

use App\Repository\ApiLogRepository;

// Add this method:
#[Route('/{id}/api-logs', name: 'admin_products_api_logs', requirements: ['id' => '\d+'], methods: ['GET'])]
public function apiLogs(int $id, ApiLogRepository $apiLogRepository): Response
{
    $entity = $this->findEntityOr404($id);
    $logs = $apiLogRepository->findByEntity('product', $entity->getExternalId());

    return $this->render('admin/products/api-logs.html.twig', [
        'entity' => $entity,
        'entity_label' => $this->getEntityLabel(),
        'route_prefix' => $this->getRoutePrefix(),
        'logs' => $logs,
        'active_tab' => 'api_logs',
    ]);
}

Step 4: Add tab to _tabs.html.twig

Add an "API Log" tab to templates/admin/products/_tabs.html.twig:

    <a href="{{ path('admin_products_api_logs', {id: entity.id}) }}"
       style="padding: 10px 20px; text-decoration: none; font-weight: 500; border-bottom: 2px solid {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'transparent' }}; margin-bottom: -2px; color: {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'var(--admin-secondary)' }};">
        API Log
    </a>

Step 5: Create the template

Create templates/admin/products/api-logs.html.twig:

{% extends 'admin/base.html.twig' %}

{% block admin_title %}Product #{{ entity.id }} - API Log{% endblock %}

{% block admin_breadcrumb %}
    <span class="admin-breadcrumb-separator">/</span>
    <a href="{{ path('admin_products_index') }}">Products</a>
    <span class="admin-breadcrumb-separator">/</span>
    <a href="{{ path('admin_products_show', {id: entity.id}) }}">#{{ entity.id }}</a>
    <span class="admin-breadcrumb-separator">/</span>
    <span>API Log</span>
{% endblock %}

{% block admin_actions %}
    <a href="{{ path('admin_products_edit', {id: entity.id}) }}" class="admin-btn admin-btn-primary">
        Edit Product
    </a>
    <a href="{{ path('admin_products_index') }}" class="admin-btn admin-btn-secondary">
        Back to List
    </a>
{% endblock %}

{% block admin_content %}
    <div class="admin-page-header">
        <h1 class="admin-page-title">Product #{{ entity.id }}</h1>
    </div>

    {% include 'admin/products/_tabs.html.twig' with {entity: entity, active_tab: active_tab} %}

    <div class="admin-card">
        <div class="admin-card-header">
            <h3 class="admin-card-title">API Log ({{ logs|length }})</h3>
        </div>
        <div class="admin-card-body" style="padding: 0; overflow-x: auto;">
            {% if logs|length == 0 %}
                <div class="admin-empty-state" style="padding: 40px;">
                    <div class="admin-empty-state-icon">&#128220;</div>
                    <div class="admin-empty-state-title">No API log entries</div>
                    <p>No API traffic has been recorded for this product yet.</p>
                </div>
            {% else %}
                <table class="admin-table">
                    <thead>
                        <tr>
                            <th>Time</th>
                            <th>Direction</th>
                            <th>Method</th>
                            <th>Path</th>
                            <th>Source</th>
                            <th>Status</th>
                            <th>Duration</th>
                            <th></th>
                        </tr>
                    </thead>
                    <tbody>
                        {% for log in logs %}
                            <tr>
                                <td style="white-space: nowrap;">{{ log.createdAt ? log.createdAt|date('Y-m-d H:i:s') : '-' }}</td>
                                <td>
                                    <span class="admin-badge admin-badge-{{ log.direction.value == 'inbound' ? 'info' : 'warning' }}">
                                        {{ log.direction.value == 'inbound' ? '&#8592; IN' : '&#8594; OUT' }}
                                    </span>
                                </td>
                                <td>
                                    <span class="admin-badge admin-badge-{{ log.method == 'GET' ? 'success' : (log.method == 'POST' ? 'info' : 'warning') }}">
                                        {{ log.method }}
                                    </span>
                                </td>
                                <td style="font-family: monospace; font-size: 13px;">{{ log.path }}</td>
                                <td>{{ log.source }}</td>
                                <td>
                                    <span class="admin-badge admin-badge-{{ log.statusCode < 300 ? 'success' : (log.statusCode < 500 ? 'warning' : 'danger') }}">
                                        {{ log.statusCode }}
                                    </span>
                                </td>
                                <td>{{ log.duration is not null ? log.duration ~ 'ms' : '-' }}</td>
                                <td>
                                    <a href="{{ path('admin_api_logs_show', {id: log.id}) }}" class="admin-btn admin-btn-sm admin-btn-secondary">View</a>
                                </td>
                            </tr>
                        {% endfor %}
                    </tbody>
                </table>
            {% endif %}
        </div>
    </div>
{% endblock %}

Step 6: Run test to verify it passes

Run: bin/phpunit tests/Controller/Admin/ProductDetailTabsTest.php Expected: All tests PASS

Step 7: Commit

git add src/Controller/Admin/ProductController.php templates/admin/products/_tabs.html.twig templates/admin/products/api-logs.html.twig tests/Controller/Admin/ProductDetailTabsTest.php
git commit -m "Add API Log tab to product detail page"

Task 9: Partner API Log Tab

Files: - Modify: src/Controller/Admin/PartnerController.php - Create: templates/admin/partners/_tabs.html.twig - Create: templates/admin/partners/api-logs.html.twig - Modify: templates/admin/partners/show.html.twig (add tabs include)

Step 1: Write the failing test

Create tests/Controller/Admin/PartnerApiLogTabTest.php:

<?php

declare(strict_types=1);

namespace App\Tests\Controller\Admin;

use App\Entity\ApiLog;
use App\Entity\Partner;
use App\Entity\User;
use App\Enum\ApiLogDirection;
use App\Enum\ProcessingState;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class PartnerApiLogTabTest extends WebTestCase
{
    private KernelBrowser $client;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->client = static::createClient();
        $this->em = static::getContainer()->get(EntityManagerInterface::class);
        $this->loginAsAdmin();
    }

    public function testShowPageDisplaysTabBar(): void
    {
        $partner = $this->createPartner('PARTNER-001');

        $this->client->request('GET', '/admin/partners/'.$partner->getId());

        $this->assertResponseIsSuccessful();
        $this->assertSelectorExists('a[href*="/admin/partners/'.$partner->getId().'/api-logs"]');
    }

    public function testApiLogsTabDisplaysLogEntries(): void
    {
        $partner = $this->createPartner('PARTNER-LOG-001');
        $this->createApiLogForPartner('PARTNER-LOG-001');

        $this->client->request('GET', '/admin/partners/'.$partner->getId().'/api-logs');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('body', '/api/v1/partners');
    }

    private function loginAsAdmin(): void
    {
        $userRepository = $this->em->getRepository(User::class);
        $admin = $userRepository->findOneBy([]);

        if (!$admin) {
            $admin = new User();
            $admin->setEmail('admin@test.com');
            $admin->setPassword('$2y$13$dummy');
            $admin->setRoles(['ROLE_ADMIN']);
            $this->em->persist($admin);
            $this->em->flush();
        }

        $this->client->loginUser($admin);
    }

    private function createPartner(string $externalId): Partner
    {
        $partner = new Partner();
        $partner->setExternalId($externalId);
        $partner->setType('B');
        $partner->setProcessingState(ProcessingState::PENDING);
        $partner->setCurrentData(['external_id' => $externalId]);
        $partner->setChangedFields([]);

        $this->em->persist($partner);
        $this->em->flush();

        return $partner;
    }

    private function createApiLogForPartner(string $externalId): ApiLog
    {
        $log = new ApiLog();
        $log->setMethod('POST')
            ->setPath('/api/v1/partners')
            ->setDirection(ApiLogDirection::INBOUND)
            ->setSource('odoo')
            ->setEntityType('partner')
            ->setExternalId($externalId)
            ->setStatusCode(200)
            ->setIpAddress('127.0.0.1');

        $this->em->persist($log);
        $this->em->flush();

        return $log;
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Controller/Admin/PartnerApiLogTabTest.php Expected: FAIL

Step 3: Add route to PartnerController

Add to src/Controller/Admin/PartnerController.php:

use App\Repository\ApiLogRepository;

#[Route('/{id}/api-logs', name: 'admin_partners_api_logs', requirements: ['id' => '\d+'], methods: ['GET'])]
public function apiLogs(int $id, ApiLogRepository $apiLogRepository): Response
{
    $entity = $this->findEntityOr404($id);
    $logs = $apiLogRepository->findByEntity('partner', $entity->getExternalId());

    return $this->render('admin/partners/api-logs.html.twig', [
        'entity' => $entity,
        'entity_label' => $this->getEntityLabel(),
        'route_prefix' => $this->getRoutePrefix(),
        'logs' => $logs,
        'active_tab' => 'api_logs',
    ]);
}

Also update the show method to pass active_tab:

return $this->renderShow($entity, ['active_tab' => 'details']);

Step 4: Create partner tabs partial and API logs template

Create templates/admin/partners/_tabs.html.twig:

{# Expects: entity (Partner), active_tab (string: 'details'|'api_logs') #}
<div style="display: flex; gap: 0; margin-bottom: 20px; border-bottom: 2px solid var(--admin-border-color);">
    <a href="{{ path('admin_partners_show', {id: entity.id}) }}"
       style="padding: 10px 20px; text-decoration: none; font-weight: 500; border-bottom: 2px solid {{ active_tab == 'details' ? 'var(--admin-primary)' : 'transparent' }}; margin-bottom: -2px; color: {{ active_tab == 'details' ? 'var(--admin-primary)' : 'var(--admin-secondary)' }};">
        Details
    </a>
    <a href="{{ path('admin_partners_api_logs', {id: entity.id}) }}"
       style="padding: 10px 20px; text-decoration: none; font-weight: 500; border-bottom: 2px solid {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'transparent' }}; margin-bottom: -2px; color: {{ active_tab == 'api_logs' ? 'var(--admin-primary)' : 'var(--admin-secondary)' }};">
        API Log
    </a>
</div>

Create templates/admin/partners/api-logs.html.twig following the same pattern as the product API logs template, but with partner-specific breadcrumbs and routes.

Update templates/admin/partners/show.html.twig to include the tabs partial:

{% include 'admin/partners/_tabs.html.twig' with {entity: entity, active_tab: active_tab|default('details')} %}

Step 5: Run test to verify it passes

Run: bin/phpunit tests/Controller/Admin/PartnerApiLogTabTest.php Expected: PASS

Step 6: Commit

git add src/Controller/Admin/PartnerController.php templates/admin/partners/ tests/Controller/Admin/PartnerApiLogTabTest.php
git commit -m "Add API Log tab to partner detail page"

Task 10: Sale Order API Log Tab

Files: - Modify: src/Controller/Admin/SaleOrderController.php - Create: templates/admin/sale_orders/_tabs.html.twig - Create: templates/admin/sale_orders/api-logs.html.twig - Modify: templates/admin/sale_orders/show.html.twig (add tabs include)

Follow the exact same pattern as Task 9 but for sale orders:

Step 1: Write the failing test

Create tests/Controller/Admin/SaleOrderApiLogTabTest.php following the same pattern as PartnerApiLogTabTest.php but using SaleOrder entity, route /admin/sale-orders/{id}/api-logs, entity type sale_order.

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Controller/Admin/SaleOrderApiLogTabTest.php Expected: FAIL

Step 3: Add route to SaleOrderController

use App\Repository\ApiLogRepository;

#[Route('/{id}/api-logs', name: 'admin_sale_orders_api_logs', requirements: ['id' => '\d+'], methods: ['GET'])]
public function apiLogs(int $id, ApiLogRepository $apiLogRepository): Response
{
    $entity = $this->findEntityOr404($id);
    $logs = $apiLogRepository->findByEntity('sale_order', $entity->getExternalId());

    return $this->render('admin/sale_orders/api-logs.html.twig', [
        'entity' => $entity,
        'entity_label' => $this->getEntityLabel(),
        'route_prefix' => $this->getRoutePrefix(),
        'logs' => $logs,
        'active_tab' => 'api_logs',
    ]);
}

Also update the show method to pass active_tab:

return $this->renderShow($entity, ['active_tab' => 'details']);

Step 4: Create templates

Same pattern as Task 9: _tabs.html.twig + api-logs.html.twig + modify show.html.twig.

Step 5: Run test to verify it passes

Run: bin/phpunit tests/Controller/Admin/SaleOrderApiLogTabTest.php Expected: PASS

Step 6: Commit

git add src/Controller/Admin/SaleOrderController.php templates/admin/sale_orders/ tests/Controller/Admin/SaleOrderApiLogTabTest.php
git commit -m "Add API Log tab to sale order detail page"

Task 11: Purge Command

Files: - Create: src/Command/ApiLogPurgeCommand.php - Test: tests/Command/ApiLogPurgeCommandTest.php

Step 1: Write the failing test

<?php

declare(strict_types=1);

namespace App\Tests\Command;

use App\Command\ApiLogPurgeCommand;
use App\Repository\ApiLogRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;

class ApiLogPurgeCommandTest extends TestCase
{
    public function testPurgesOldEntries(): void
    {
        $repository = $this->createMock(ApiLogRepository::class);
        $repository->expects(self::once())
            ->method('deleteOlderThan')
            ->with(self::callback(function (\DateTimeImmutable $cutoff): bool {
                $expected = new \DateTimeImmutable('-30 days');
                $diff = abs($cutoff->getTimestamp() - $expected->getTimestamp());

                return $diff < 5; // within 5 seconds
            }))
            ->willReturn(42);

        $command = new ApiLogPurgeCommand($repository);
        $tester = new CommandTester($command);

        $tester->execute([]);

        self::assertSame(Command::SUCCESS, $tester->getStatusCode());
        self::assertStringContainsString('42', $tester->getDisplay());
    }

    public function testReportsZeroWhenNothingToPurge(): void
    {
        $repository = $this->createMock(ApiLogRepository::class);
        $repository->method('deleteOlderThan')->willReturn(0);

        $command = new ApiLogPurgeCommand($repository);
        $tester = new CommandTester($command);

        $tester->execute([]);

        self::assertSame(Command::SUCCESS, $tester->getStatusCode());
        self::assertStringContainsString('0', $tester->getDisplay());
    }
}

Step 2: Run test to verify it fails

Run: bin/phpunit tests/Command/ApiLogPurgeCommandTest.php Expected: FAIL - class not found

Step 3: Write the command

<?php

declare(strict_types=1);

namespace App\Command;

use App\Repository\ApiLogRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:api-log:purge',
    description: 'Purge API log entries older than 30 days',
)]
class ApiLogPurgeCommand extends Command
{
    public function __construct(
        private readonly ApiLogRepository $apiLogRepository,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $cutoff = new \DateTimeImmutable('-30 days');
        $deleted = $this->apiLogRepository->deleteOlderThan($cutoff);

        $io->success(\sprintf('Purged %d API log entries older than 30 days.', $deleted));

        return Command::SUCCESS;
    }
}

Step 4: Run test to verify it passes

Run: bin/phpunit tests/Command/ApiLogPurgeCommandTest.php Expected: PASS

Step 5: Commit

git add src/Command/ApiLogPurgeCommand.php tests/Command/ApiLogPurgeCommandTest.php
git commit -m "Add app:api-log:purge command for 30-day retention"

Task 12: Final Verification

Step 1: Run full test suite

Run: bin/phpunit Expected: All tests pass

Step 2: Run static analysis

Run: vendor/bin/phpstan analyse Expected: No errors

Step 3: Run code style fixer

Run: vendor/bin/php-cs-fixer fix --dry-run --diff Expected: No style violations (or fix them)

Step 4: Commit any fixes

git add -A
git commit -m "Fix code style and static analysis issues"