Skip to main content
ImmutableLog logo
Back
SymfonyPHP

Symfony Integration

Integrate ImmutableLog into any Symfony application with an EventSubscriber. Sending happens on `kernel.terminate` — after the response is delivered to the client — using Symfony's native HttpClient.

Installation

The subscriber uses the Symfony HttpClient and uid components.

bash
composer require symfony/http-client symfony/uid

EventSubscriber code

Save it in `src/EventSubscriber/ImmutableLogSubscriber.php`. `onRequest` records the timestamp and request_id; `onException` stores the exception; `onTerminate` builds and sends the event after the response. Query params go through `redact()`.

php
<?php
// src/EventSubscriber/ImmutableLogSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class ImmutableLogSubscriber implements EventSubscriberInterface
{
    // Chaves cujos valores sao mascarados antes do envio (PII/segredos).
    private const SENSITIVE = [
        'password', 'pwd', 'senha', 'token', 'access_token', 'refresh_token', 'api_key',
        'apikey', 'secret', 'client_secret', 'authorization', 'auth', 'credit_card',
        'card_number', 'cvv', 'cpf', 'otp', 'code',
    ];

    public function __construct(
        private HttpClientInterface $http,
        private string $apiKey,
        private string $service = 'symfony-service',
        private string $env = 'prod',
        private string $apiUrl = 'https://api.immutablelog.com',
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST   => ['onRequest', 4096],
            KernelEvents::EXCEPTION => 'onException',
            KernelEvents::TERMINATE => 'onTerminate',
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }
        $req = $event->getRequest();
        $req->attributes->set('imtbl.started_at', microtime(true));
        $req->attributes->set(
            'imtbl.request_id',
            $req->headers->get('X-Request-Id') ?? Uuid::v4()->toRfc4122()
        );
    }

    public function onException(ExceptionEvent $event): void
    {
        $event->getRequest()->attributes->set('imtbl.exception', $event->getThrowable());
    }

    // TERMINATE roda após a resposta ser enviada — fire-and-forget natural.
    public function onTerminate(TerminateEvent $event): void
    {
        try {
            if (empty($this->apiKey)) {
                return;
            }
            $req  = $event->getRequest();
            $res  = $event->getResponse();
            $path = $req->getPathInfo();
            if (in_array($path, ['/health', '/healthz', '/metrics'], true)) {
                return;
            }

            $startedAt = $req->attributes->get('imtbl.started_at', microtime(true));
            $latencyMs = (int) ((microtime(true) - $startedAt) * 1000);
            $status    = $res->getStatusCode();
            $requestId = $req->attributes->get('imtbl.request_id');
            $exc       = $req->attributes->get('imtbl.exception');

            $kind = $exc ? 'error'
                : ($status >= 400 ? 'error' : ($status >= 300 ? 'info' : ($status >= 200 ? 'success' : 'info')));
            $eventName = $req->attributes->get('imtbl.event_name')
                ?? 'http.' . $req->getMethod() . '.' . $path;

            $payload = [
                'id'        => Uuid::v4()->toRfc4122(),
                'kind'      => $kind,
                'message'   => $req->getMethod() . ' ' . $path . ' -> ' . $status,
                'timestamp' => gmdate('c'),
                'context'   => [
                    'ip'         => $req->getClientIp() ?? 'unknown',
                    'user_agent' => $req->headers->get('User-Agent') ?? 'unknown',
                ],
                'request'   => [
                    'request_id'   => $requestId,
                    'method'       => $req->getMethod(),
                    'path'         => $path,
                    // query params passam por redact() — nunca enviar segredos em claro.
                    'query_params' => self::redact($req->query->all()) ?: null,
                ],
                'metrics'   => ['latency_ms' => $latencyMs, 'status_code' => $status],
                'severity'  => $kind === 'error' ? 'high' : 'low',
            ];
            if ($kind === 'error') {
                $err = [
                    'status_code' => $status,
                    'retryable'   => in_array($status, [408, 429, 500, 502, 503, 504], true),
                ];
                if ($exc instanceof \Throwable) {
                    $err['exception']         = get_class($exc);
                    $err['exception_message'] = mb_substr($exc->getMessage(), 0, 500);
                }
                $payload['error'] = $err;
            }

            // Trilha: attribute imtbl.trail -> header X-Imtbl-Trail.
            $trail = self::sanitizeTrail(
                $req->attributes->get('imtbl.trail') ?? $req->headers->get('X-Imtbl-Trail')
            );

            // Todos os valores de meta precisam ser strings.
            $meta = [
                'type'       => $kind,
                'event_name' => $eventName,
                'service'    => $this->service,
                'request_id' => $requestId,
                'env'        => $this->env,
            ];
            if ($trail !== null) {
                $meta['immutable_trail'] = $trail;
            }

            $headers = [
                'Authorization' => "Bearer {$this->apiKey}",
                'Content-Type'  => 'application/json',
                // Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
                'Idempotency-Key'         => "{$eventName}-{$requestId}",
                'Request-Id'              => $requestId,
                'X-Client-TZ'             => 'America/Sao_Paulo',
                'X-Client-Offset-Minutes' => '-180',
            ];
            if ($trail !== null) {
                $headers['X-Imtbl-Trail'] = $trail;
            }

            $this->http->request('POST', $this->apiUrl . '/v1/events', [
                'headers' => $headers,
                'json'    => ['payload' => json_encode($payload), 'meta' => $meta],
                'timeout' => 5,
            ]);
        } catch (\Throwable $e) {
            // Never let audit logging break the application.
        }
    }

    private static function redact(array $data): array
    {
        foreach ($data as $k => $v) {
            if (is_string($k) && in_array(strtolower($k), self::SENSITIVE, true)) {
                $data[$k] = '***REDACTED***';
            } elseif (is_array($v)) {
                $data[$k] = self::redact($v);
            }
        }
        return $data;
    }

    // Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
    private static function sanitizeTrail(?string $value): ?string
    {
        if ($value === null) {
            return null;
        }
        $v = trim($value);
        if ($v === '') {
            return null;
        }
        $v = str_replace(':', '-', $v);
        if (mb_strlen($v) > 256) {
            $v = mb_substr($v, 0, 256);
        }
        return $v;
    }
}

Registration in services.yaml

Register the subscriber by injecting the credentials. HttpClientInterface is autowired automatically. The subscriber is detected via the kernel.event_subscriber tag.

yaml
# config/services.yaml
services:
    App\EventSubscriber\ImmutableLogSubscriber:
        arguments:
            $apiKey: '%env(IMTBL_API_KEY)%'
            $service: 'my-symfony-service'
            $env: '%kernel.environment%'
        # $http (HttpClientInterface) é autowired automaticamente.
        # A tag kernel.event_subscriber é aplicada via autoconfigure.

Never hardcode the token. Set IMTBL_API_KEY in .env.local and never commit it.

How it works

kernel.request

Records request_id and start timestamp

kernel.exception

Stores the unhandled exception on the request

kernel.terminate

Runs after the response — builds and sends the event

Custom event and trail

Set the `imtbl.event_name` and `imtbl.trail` request attributes in any controller. `onTerminate` reads these values when building the event.

php
<?php
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

class PaymentController
{
    #[Route('/payments', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        // Sobrescreve o nome do evento e agrupa numa trilha auditavel.
        $request->attributes->set('imtbl.event_name', 'payment.created');
        $request->attributes->set('imtbl.trail', 'order-7782');

        // ... lógica de negócio / business logic ...

        return new JsonResponse(['ok' => true]);
    }
}

Auto default: http.METHOD.path.

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline. Set the `imtbl.trail` request attribute, or propagate it across services with the `X-Imtbl-Trail` header. The value goes through `sanitizeTrail` before sending.

php
#[Route('/flows/{id}/run', methods: ['POST'])]
public function run(Request $request, string $id): JsonResponse
{
    $request->attributes->set('imtbl.trail', "flow-{$id}");
    // ... business logic ...
    return new JsonResponse(['status' => 'ok']);
}

The trail cannot be empty, exceed 256 characters, or contain `:` — violations return `400 invalid_immutable_trail`. `sanitizeTrail` fixes the value before sending.

Response and status codes

The ingestion API is asynchronous. Success returns 202 (new) or 200 (idempotent duplicate). Since sending runs on kernel.terminate, these codes matter mostly if you send events manually.

StatusMeaning
202Event accepted and queued (new)
200Idempotency-Key already exists — duplicate: true
400Missing/empty Idempotency-Key or invalid_immutable_trail
403Inactive/expired subscription, scope or retention
413payload_too_large (server limit: 16KB)
429monthly_limit_exceeded — do not retry
503mempool_full — transient, retryable

meta fields

Beyond the payload, the meta object carries the fields the SIEM uses to normalize (ECS) and enrich the event. All meta values are strings.

Field in metaWhat it does in the SIEM
event_nameThe event label (e.g. http.GET.auth-me-user, Payment error) — the identifier shown in the listing. Becomes event.action and derives event.category (e.g. a name with loginauthentication).
client_ipBecomes source.ip with maximum precedence and provenance client_asserted. See the section below.
immutable_trailBecomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user).
typeEvent severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.)
service, env, request_idMetadata: indexed and queryable in the dashboard (search/filters).

client_ip — end-user IP

The problem it solves

Between the browser and the core there are proxies/ALB. The IP the core sees on the connection is the previous hop (the client's backend or an AWS proxy), not the user's. Without action, source.ip would be the proxy IP — useless for geo, threat and IP-based detection. (Real example: XFF arrived as 44.192.13.3 (AWS) while the user was 179.110.4.205.)

The solution

The client's backend is the only one that sees the browser's real IP — so it forwards that IP in meta.client_ip when calling POST /v1/events.

How the SIEM handles it

  • Validates it is an IP (v4/v6). A non-IP value is ignored — it does not drop the event.
  • Writes to source.ip with maximum precedence (see order below).
  • Stamps imtbl.source_ip_origin = "client_asserted" — the UI shows the “User IP” badge.
  • Feeds GeoIP, threat intel and IP-based detection rules.
source.ip precedence:client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IP

Provenance and trust

client_ip is asserted by the tenant — the client's backend claims the IP, and it is spoofable by whoever controls that backend. However it is self-contained to the tenant (it never crosses another tenant's boundary). A threat/geo hit on a client_asserted value is a claim, not an edge observation.

How to obtain the IP in your backend

Take the first IP from X-Forwarded-For (closest to the user); as a fallback, use the connection IP.

php
function client_ip(): ?string {
    $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
    if ($xff !== '') {
        return trim(explode(',', $xff)[0]); // primeiro hop = IP do usuario
    }
    return $_SERVER['REMOTE_ADDR'] ?? null;
}

$meta = ['event_name' => 'user.login', 'client_ip' => client_ip()];

Payload example

json
{
  "payload": "{\"user\":\"bob\",\"action\":\"login\"}",
  "meta": {
    "event_name": "user.login",
    "client_ip": "179.110.4.205",
    "immutable_trail": "user-bob"
  }
}

PII note: client_ip is PII and gets sealed into the immutable block (permanent). Decide consciously between forensic evidence and “right to be forgotten” before sending it.

This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.