---
url: https://simpledatalogs.com/docs/sdks/laravel.md
description: >-
  Track events, Eloquent model changes and sign-ins, forward Laravel log
  channels, and record requests, jobs, queries and exceptions in simpledatalogs
  through a disk outbox, with event ids so everything counts once.
---

# Laravel

The `simpledatalogs/laravel` package sends events, model changes, sign-ins, your Laravel log channels and telemetry (requests, jobs, scheduled tasks, commands, queries, outgoing HTTP and exceptions) to simpledatalogs. Everything only appends to a disk outbox, shipped in batches after each response. Your requests never wait on the network, and every line carries an `event_id` and `ts`, so retries count once.

* PHP 8.2+, Laravel 11, 12 or 13 (13 needs PHP 8.3+), Monolog 3.

## Install

```sh
composer require simpledatalogs/laravel
php artisan vendor:publish --tag=simpledatalogs-config
```

Add a **secret key** to `.env`:

```sh
SIMPLEDATALOGS_KEY=sdl_sec_…
```

::: info Early access
The package is published on Packagist with the public launch. Until then, your invitation includes the package source, which you can add as a Composer `path` repository.
:::

## Track events

```php
use SimpleDataLogs\Laravel\Facades\SimpleDataLogs;

SimpleDataLogs::track('signup', ['plan' => 'pro', 'seats' => 3]);
```

Each event gets an `event_id` and `ts` now, `props.app_env`, and the user's id when one is known (`userId:` argument, `SimpleDataLogs::setUserId()`, or the auth listener below). Props must be strings, numbers or booleans (backed enums send their value); anything else is skipped. `track()` only appends to the disk outbox and never throws.

Events tracked from a queue worker, an Artisan command or the scheduler also carry `props.app_state: background`, so the [Sessions tab](/guide/web-analytics) counts them as passive; pass your own `app_state` to override it. Web requests and Octane carry none.

## Model events (opt-in)

```php
use SimpleDataLogs\Laravel\TracksModelEvents;

class Order extends Model
{
    use TracksModelEvents;

    protected array $trackedAttributeValues = ['status']; // optional: values allowed out
}
```

| Event | Props |
|---|---|
| `order_created`, `order_deleted`, `order_restored` (soft deletes) | `model` (morph class), `model_id` |
| `order_updated` | The same, plus `changed`: the changed attribute **names**, e.g. `status,note`; an update that only touched timestamps sends nothing |

Values are never sent, except `attributes.<name>` for attributes listed in `$trackedAttributeValues` and not `$hidden`. Override `trackedEventName($action)` to rename.

## Auth events (opt-in)

```sh
SIMPLEDATALOGS_TRACK_AUTH=true
```

Sends `user_logged_in`, `user_logged_out` and `user_registered`, and puts the signed-in user's id (`getAuthIdentifier()`) as `user_id` on every event of the request, including model events. It reads the user only when the guard already resolved one, so it adds no query.

## Forward a channel

Add a tap to any channel in `config/logging.php`. The channel keeps writing its own file; the tap adds a copy for simpledatalogs:

```php
'partner_api' => [
    'driver' => 'daily',
    'path' => storage_path('logs/partner-api.log'),
    'level' => 'debug',
    'days' => 90,
    'tap' => [\SimpleDataLogs\Laravel\ForwardToSimpleDataLogs::class . ':partner_api'],
],
```

The tap's parameter (`:partner_api`) names the channel, because Laravel names every Monolog logger after `APP_ENV`. It becomes the `channel` prop on each line.

Add the same tap, with its own name, to every channel you want forwarded, such as `daily`.

## Telemetry

Telemetry is **on by default** from version 1.0 once `SIMPLEDATALOGS_KEY` is set. With no code to add, the package records every request, queued job, scheduled task and Artisan command. Each one becomes one line, a *bundle*, written to the outbox when it ends, with its queries, outgoing HTTP calls, cache counts, mail, notifications and exceptions inside it. A request with everything inside it usually counts as one event.

To turn it all off:

```dotenv
SIMPLEDATALOGS_TELEMETRY=false
```

| Signal | What is sent | Switch |
|---|---|---|
| Requests | Method, route pattern (`GET /orders/{order}`), route name, status, duration, peak memory, user id | `SIMPLEDATALOGS_TELEMETRY_REQUESTS` |
| Queries | SQL with `?` placeholders and the connection; the same SQL run 37 times is one child with `count: 37`, the summed time and `max_ms` | `SIMPLEDATALOGS_TELEMETRY_QUERIES` |
| Outgoing HTTP | `METHOD host`, status and duration, for Laravel's HTTP client | `SIMPLEDATALOGS_TELEMETRY_HTTP` |
| Cache | Hit, miss, write and forget counts per store | `SIMPLEDATALOGS_TELEMETRY_CACHE` |
| Mail and notifications | Class name, channel and duration | `SIMPLEDATALOGS_TELEMETRY_MAIL`, `SIMPLEDATALOGS_TELEMETRY_NOTIFICATIONS` |
| Jobs | Class, queue, connection, attempts, duration and status, linked to the request that dispatched the job | `SIMPLEDATALOGS_TELEMETRY_JOBS` |
| Scheduled tasks | Command line, cron expression, interval, duration, status and skipped runs | `SIMPLEDATALOGS_TELEMETRY_TASKS` |
| Commands | Name, exit code and duration | `SIMPLEDATALOGS_TELEMETRY_COMMANDS` |
| Exceptions | Type, message, whether the app handled it, and up to 50 stack frames | `SIMPLEDATALOGS_TELEMETRY_EXCEPTIONS` |
| Log links | `trace_id` and `span_id` on every log record | `SIMPLEDATALOGS_TELEMETRY_LOGS` |

`simpledatalogs:ship`, `schedule:run` and long-running workers (`queue:work`, `horizon`, `octane:start` and the like) are not recorded as commands. The lists are `telemetry.ignore_commands` and `telemetry.ignore_jobs` in `config/simpledatalogs.php`.

### What never leaves

* Query bindings and SQL literals: `where email = 'a@b.com'` is sent as `where email = ?`.
* Headers, cookies, request and response bodies, and the URL path and query string of outgoing calls.
* Mail addresses, subjects and bodies, notification payloads, and command arguments.
* Route parameters: the route pattern keeps `{order}`, never the value.
* Cache keys, unless you set `SIMPLEDATALOGS_TELEMETRY_CACHE_KEYS=true`. Even then, digits and hashes are sent as `*` (`user:*:profile`).

`drop_context_keys` applies to telemetry props too.

### Traces

Each request, job, task and command gets a trace id. If a request arrives with a W3C `traceparent` header, it joins the caller's trace. A job dispatched during a request carries the trace in its payload, so it appears under that request. Log lines written meanwhile carry the same `trace_id`, so you can go from a log line to its request.

Wrap your own work in a span:

```php
use SimpleDataLogs\Laravel\Facades\SimpleDataLogs;

$pdf = SimpleDataLogs::span('render-pdf', fn () => $renderer->render($invoice), ['pages' => 3]);
```

### Sampling

Every request, job, task and command is sent, with exact totals and its exceptions. Its children (each query and call) are sent when it was slow, when it failed, or one time in ten:

| Variable | Default | Meaning |
|---|---|---|
| `SIMPLEDATALOGS_SLOW_REQUEST_MS` | `500` | Requests slower than this always send their children |
| `SIMPLEDATALOGS_SLOW_JOB_MS` | `5000` | The same for jobs, tasks and commands |
| `SIMPLEDATALOGS_DETAIL_EVERY` | `10` | Other roots send their children one time in this many |
| `SIMPLEDATALOGS_SAMPLE_RATE` | `1` | Below 1, keeps only that share of roots, each with a `sample_weight`; slow and failed ones are always kept |

### Release and grouping

Every bundle carries a `release`: `SIMPLEDATALOGS_RELEASE` if set, else the deployed git SHA (from a `REVISION` file or `.git`), else `APP_VERSION`.

To group exceptions your own way, set `fingerprint` in `config/simpledatalogs.php` to a callable that takes the exception and returns a string.

### Limits and cost

* At most 60 children per bundle; the rest fold into one `(other)` child per kind, so totals stay right.
* At most 5 exceptions, 50 frames and 8 KB per stack, and 100 props.
* At most 64 KB per bundle. A bigger one drops its children and keeps its totals.
* Recording only appends to memory, then writes one line when the request ends. It costs well under half a millisecond for a request with 20 queries, and it never throws.

Octane works as is. Queued jobs write their bundle as soon as they finish; for busy workers, schedule `simpledatalogs:ship` as shown below.

## Sending: no cron needed

By default the package ships after each response: once Laravel has sent the response, it sends what is waiting for up to 3 seconds. Another process already shipping holds a lock, so busy apps still batch.

| `SIMPLEDATALOGS_DELIVERY` | What happens |
|---|---|
| `terminate` (default) | Ships after each response or Artisan command |
| `queue` | Queues one `ShipOutboxJob` (unique for 60 s) on `SIMPLEDATALOGS_QUEUE` |
| `schedule` | Leaves it to the scheduled command below |

Long-running queue workers terminate only when they stop, so for busy workers also schedule the ship command, which works with every mode:

```php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('simpledatalogs:ship')->everyMinute()->withoutOverlapping();
```

Each run ships for about 55 seconds, so lines leave within about 5 seconds. Run one pass by hand with:

```sh
php artisan simpledatalogs:ship --once
```

## What each line holds

| Field | From |
|---|---|
| `event_id` | A UUID set when the record is written |
| `ts` | The record's time, with milliseconds and offset |
| `level` | The Monolog level, lowercased |
| `message` | The record's message |
| `props` | The context (minus `drop_context_keys`, or only `context_allowlist`), `channel`, `app_env`, `extra`, and the typed `message_fields` |
| `trace_id`, `span_id` | The request, job, task or command the line was written in, when telemetry is on |

So `Log::channel('partner_api')->error('Payment failed', ['order_id' => 7])` arrives as an `error` log line with `props.order_id = 7` and `props.channel = "partner_api"`.

## Split the message into fields

If a channel logs space-separated values, `message_fields` turns them into typed props. For a line such as `/catalog/products/get_by_sku 1 3`:

```php
// config/simpledatalogs.php
'message_fields' => ['endpoint', 'success:bool', 'attempts:int'],
```

Types are `string` (the default), `int`, `float` and `bool`. A message with a different number of tokens is sent without them. To do the same on the server side for any sender, use a [text template](/api/templates).

## Keep personal data out

Context arrays often carry more than you'd like. Two settings control what leaves your server:

```php
// config/simpledatalogs.php
'drop_context_keys' => ['queries', 'email', 'password'],  // never sent
'context_allowlist' => null,                              // or ['order_id', 'plan'] to send only these
```

`queries` is dropped by default. NDJSON lines are stored as sent, so filter here; see [Privacy for senders](/reference/privacy).

## Configuration

Every setting can come from the environment:

| Variable | Default | Meaning |
|---|---|---|
| `SIMPLEDATALOGS_KEY` | — | The deployment's secret ingest key |
| `SIMPLEDATALOGS_URL` | `https://edge.simpledatalogs.com/v1/ingest` | The ingest endpoint |
| `SIMPLEDATALOGS_OUTBOX_PATH` | `storage/simpledatalogs/outbox` | The disk outbox folder |
| `SIMPLEDATALOGS_MAX_OUTBOX_BYTES` | `536870912` (512 MB) | The outbox's disk cap |
| `SIMPLEDATALOGS_MAX_FILE_BYTES` | `67108864` (64 MB) | A pending file this big is handed to the shipper |
| `SIMPLEDATALOGS_BATCH_LINES` | `50` | Lines per request |
| `SIMPLEDATALOGS_BATCH_BYTES` | `262144` | Bytes per request |
| `SIMPLEDATALOGS_SHIP_SECONDS` | `55` | How long one ship run keeps shipping |
| `SIMPLEDATALOGS_DELIVERY` | `terminate` | `terminate`, `queue` or `schedule` |
| `SIMPLEDATALOGS_TERMINATE_SHIP_SECONDS` | `3` | How long shipping after a response may take |
| `SIMPLEDATALOGS_QUEUE` | default queue | The queue for `ShipOutboxJob` |
| `SIMPLEDATALOGS_TRACK_AUTH` | `false` | The auth listener |
| `SIMPLEDATALOGS_TELEMETRY` | `true` | Telemetry as a whole (needs the key) |
| `SIMPLEDATALOGS_RELEASE` | git SHA, else `APP_VERSION` | The release on every bundle |

`message_fields`, `drop_context_keys`, `context_allowlist`, `fingerprint` and the telemetry lists are set in `config/simpledatalogs.php`. The per-signal and sampling variables are in [Telemetry](#telemetry).

## Delivery

* A batch leaves the outbox only after a `202`. At most 50 lines and 256 KB per request, gzipped.
* `413` halves the batch, and each `202` doubles it back. A single line too large on its own moves to `outbox/rejected/`.
* `415` moves the batch to `outbox/rejected/`, with one error-log line.
* `401` pauses shipping for 10 minutes and keeps everything.
* `429`, `503` and network errors back off with full jitter, 1 second to 5 minutes, never sooner than `Retry-After`.
* Big backlogs stream one batch at a time, so memory stays at about one batch, and a crash resends only the batch in flight.
* One shipper per outbox at a time (`flock`). Logging never throws: a failed append goes to PHP's error log.

### Disk caps

The outbox never takes more than `SIMPLEDATALOGS_MAX_OUTBOX_BYTES`. Past it, the oldest unsent file goes, with one warning per process. A line over 200 KB is cut down to its `channel`, `app_env` and the first 8 KB of its message. `outbox/rejected/` rotates at 16 MB.

## Next steps

* [Text templates](/api/templates): server-side parsing for any sender.
* [Sentry](/integrations/sentry): send exceptions from `sentry/sentry-laravel` too.
* [Prompt recipes](/agents/recipes#add-simpledatalogs-to-a-laravel-app): let your coding agent set it up.
