Skip to main content
ImmutableLog logo
Back
LaravelPHP

Laravel Integration

Integrate ImmutableLog into any Laravel application with a terminable middleware. The `terminate()` method runs after the response is sent to the client — so sending is naturally fire-and-forget, adding no latency.

Installation

The middleware uses Guzzle (already present in most Laravel projects) and ramsey/uuid (bundled with the framework).

bash
# Guzzle já vem no Laravel; instale se necessário:
composer require guzzlehttp/guzzle

Middleware code

Save it in `app/Http/Middleware/ImmutableLogMiddleware.php`. `handle()` records the timestamp and request_id; `terminate()` builds the event and sends it after the response. Query params go through `redact()`.

php
<?php
// app/Http/Middleware/ImmutableLogMiddleware.php
namespace App\Http\Middleware;

use Closure;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Ramsey\Uuid\Uuid;

class ImmutableLogMiddleware
{
    // 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 handle(Request $request, Closure $next)
    {
        $request->attributes->set(
            'imtbl.request_id',
            $request->header('X-Request-Id') ?? Uuid::uuid4()->toString()
        );
        $request->attributes->set('imtbl.started_at', microtime(true));

        return $next($request);
    }

    // terminate() roda DEPOIS da resposta ser enviada — fire-and-forget natural.
    public function terminate(Request $request, $response): void
    {
        try {
            $apiKey = env('IMTBL_API_KEY');
            if (empty($apiKey)) {
                return;
            }
            if (in_array($request->getPathInfo(), ['/health', '/healthz', '/metrics'], true)) {
                return;
            }

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

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

            $context = ['ip' => $request->ip(), 'user_agent' => $request->userAgent() ?? 'unknown'];
            if ($user = $request->user()) {
                $context['user_id'] = $user->getAuthIdentifier();
                $context['email']   = $user->email ?? null;
            }

            $payload = [
                'id'        => Uuid::uuid4()->toString(),
                'kind'      => $kind,
                'message'   => $request->method() . ' /' . $request->path() . ' -> ' . $status,
                'timestamp' => gmdate('c'),
                'context'   => $context,
                'request'   => [
                    'request_id'   => $requestId,
                    'method'       => $request->method(),
                    'path'         => '/' . $request->path(),
                    // query params passam por redact() — nunca enviar segredos em claro.
                    'query_params' => self::redact($request->query()) ?: null,
                ],
                'metrics'   => ['latency_ms' => $latencyMs, 'status_code' => $status],
                'severity'  => $kind === 'error' ? 'high' : 'low',
            ];
            if ($kind === 'error') {
                $payload['error'] = [
                    'status_code' => $status,
                    'retryable'   => in_array($status, [408, 429, 500, 502, 503, 504], true),
                ];
            }

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

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

            $headers = [
                'Authorization' => "Bearer {$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;
            }

            (new Client(['timeout' => 5]))->post(
                (env('IMTBL_URL') ?: 'https://api.immutablelog.com') . '/v1/events',
                ['headers' => $headers, 'json' => ['payload' => json_encode($payload), 'meta' => $meta]]
            );
        } 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;
    }
}

Middleware registration

On Laravel 11+, register it in `bootstrap/app.php`. On earlier versions, add it to the `$middleware` property of `app/Http/Kernel.php`.

php
<?php
// Laravel 11+ — bootstrap/app.php
use App\Http\Middleware\ImmutableLogMiddleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        // Global: captura todas as requisições.
        $middleware->append(ImmutableLogMiddleware::class);
    })
    ->create();

// Laravel <= 10 — app/Http/Kernel.php
// protected $middleware = [
//     // ...
//     \App\Http\Middleware\ImmutableLogMiddleware::class,
// ];

Never hardcode the token. Set IMTBL_API_KEY in .env and ensure .env is in .gitignore.

How it works

handle()

Records request_id and start timestamp

next($request)

The app processes and the response is sent to the client

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. `terminate()` reads these values when building the event.

php
<?php
use Illuminate\Http\Request;

class PaymentController
{
    public function store(Request $request)
    {
        // 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 response()->json(['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::post('/flows/{id}/run', function (Request $request, string $id) {
    $request->attributes->set('imtbl.trail', "flow-{$id}");
    // ... business logic ...
    return response()->json(['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 the middleware runs in 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

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