# Integrating into a framework


`spatie/flare-client-php` is the framework-agnostic core that all Flare PHP integrations are built on. If you maintain a framework integration (or you want to wire Flare into a custom application), this guide shows the call sequence the client expects.

If you use Laravel, install [`spatie/laravel-flare`](https://github.com/spatie/laravel-flare) instead. It implements everything described here for you.

## Building blocks

Before getting into the wiring, here is the vocabulary the rest of this guide uses. 

### Configuration and bootstrap

- **`FlareConfig`**. The user-facing configuration object that your framework's config maps onto.
- **`FlareProvider`**. Wires every Flare service onto your container in two phases: `register()` and `boot()`.
- **`Container`**. Flare's PSR-11 container. Bring your own if your framework already has one.
- **`Flare`**. The runtime object you hand around your application after `boot()`.

### Tracing and sampling

- **`Tracer`**. Produces traces and spans. Recorders call into it.
- **`Sampler`**. Decides which traces are sampled. See [Sampling](/docs/php/performance/sampling).
- **`EntryPoint` and `EntryPointResolver`**. Describe the request, command, or job that initiated the current work. See [Entry points](/docs/protocol/general/entry-points).
- **`Lifecycle`**. Manages the trace lifecycle stages and subtask mode. See [Application lifecycle](/docs/php/general/application-lifecycle).

### Data collection

- **`Recorder`**. Observes runtime work and produces spans or span events. See [Spans](/docs/php/data-collection/spans).
- **`Middleware`**. Decorates an error report inside the report pipeline. See [Customise error report](/docs/php/errors/customise-error-report).
- **`AttributesProvider`**. Turns a framework object (request, command input, job) into the attribute array a recorder records. See [Attribute providers](/docs/php/general/attribute-providers).
- **`CollectsResolver`**. Translates the `collects` configuration array on `FlareConfig` into recorder, middleware, and resource registrations.

### Errors and logs

- **`Logger`**. The standalone log recorder, accessed as `$flare->log()`. See [Logs](/docs/php/logs/introduction).
- **`Reporter`**. Sends error reports through the middleware pipeline.
- **`ReportFactory`**. The in-flight error report that middleware decorates before it's flushed.
- **`SentReports`**. A registry of reports sent during the current request, used for tests and "view in Flare" links.

### Transport and metadata

- **`Sender`**. The transport at the bottom of the client. Implements a single `post()` method that ships a payload (error, trace, or log batch). Built-in: `CurlSender` (default), `DaemonSender`, `GuzzleSender`, `NullSender`. See the [Senders](#senders) section below for switching senders or writing your own.
- **`Api`**. Wraps the `Sender` with queueing and retries.
- **`Resource`**. The long-lived application descriptor (service, host, OS, runtime, git, SDK). See [Resources](/docs/protocol/general/resources).
- **`Scope`**. The instrumentation scope (telemetry SDK name and version). See [Scope](/docs/protocol/general/resources#scope).

## Bootstrap

Framework integrations build a `Flare` instance themselves through `FlareProvider`.

The flow is the same in every integration: build a `FlareConfig`, instantiate `FlareProvider` with your container(see later), run its `register()` and `boot()` phases, and resolve `Flare` out of the container.

```php
use Spatie\FlareClient\Flare;
use Spatie\FlareClient\FlareConfig;
use Spatie\FlareClient\Support\Container;
use Spatie\FlareClient\FlareProvider;

$config = FlareConfig::make('YOUR-API-KEY')
    ->useDefaults()
    ->applicationPath('/path/to/your/application/root');
    
$container = Container::instance();

$provider = new FlareProvider($config, $container);

$provider->register();
$provider->boot();

$flare = $container->get(Flare::class);

$flare->registerFlareHandlers();
```

`useDefaults()` enables the standard set of recorders (requests, queries, jobs, queues, views, filesystem, Redis commands, cache events, glows, and so on). You can opt out per recorder with the matching `ignore*` methods on the config, or skip `useDefaults()` and enable recorders one by one.

### Register and boot phases

`FlareProvider` is modeled after Laravel's service provider lifecycle, even when used outside of Laravel. There are two phases:

- **`register()`** binds every Flare service onto the container as a singleton: the `Sender`, `Api`, `Sampler`, `Resource`, `Scope`, `EntryPointResolver`, `Tracer`, `Logger`, `Reporter`, `Lifecycle`, every recorder, every middleware, and finally `Flare` itself. Nothing is instantiated yet, so this phase is cheap and side-effect free. This is also the phase where your framework integration gets to override a binding (for example, to register a framework-specific request attribute provider) before anything is resolved.
- **`boot()`** asks the recorders to register themselves with the framework hooks they need (event listeners, middleware, dispatcher overrides, etc.). Recorders that need to react to framework events do that here, after every binding is in place and resolvable.

If `apiToken` is empty on the config, `FlareProvider::$mode` is set to `FlareMode::Disabled` and `boot()` becomes a no-op so a missing key never crashes a production deploy and thus will never send data to Flare.

### Bringing your own container

`FlareProvider`'s constructor accepts either Flare's built-in container (`Spatie\FlareClient\Support\Container`) or any [PSR-11](https://www.php-fig.org/psr/psr-11/) container that also exposes the two methods Flare uses to register bindings:

- `singleton(string $class, Closure|string|array|null $builder = null): void`
- `bind(string $class, Closure|null $builder = null): void`

Plus reflection-based autowiring: when a class is requested that has not been bound, the container should look at the constructor signature and resolve each parameter through itself. Flare's recorders rely on this so framework integrations can pass `['config' => $config]` to a recorder and have the rest of the constructor parameters pulled from the container automatically.

If your framework doesn't have a container at all yet, you can fall back to Flare's global instance:

```php
use Spatie\FlareClient\Support\Container;

$container = Container::instance();
```

### Extending `FlareConfig`

For most frameworks, `FlareConfig` covers the standard options. When your framework adds settings of its own, the recommended path is to extend `FlareConfig` rather than carry the framework state somewhere else.

A subclass lets you:

- Add framework-specific properties alongside the built-in ones, and pass them through the constructor in a single call.
- Provide a static factory that reads your framework's config files. `spatie/laravel-flare` uses this pattern via `FlareConfig::fromLaravelConfig()`, which reads `config('flare.*')` and returns a fully built config object.
- Override the `defaultCollects()` method (when present) to seed the collect list with framework-specific recorders before user code calls `useDefaults()`.

### Custom `CollectsResolver`

`CollectsResolver` is the bridge between the `collects` array on `FlareConfig` and the actual recorder / middleware / resource-modifier registrations on the container. When your framework needs to register recorders or middleware that the base resolver doesn't know about, extend `CollectsResolver`.

Two extension points cover most cases:

- **`handleUnknownCollectType(FlareCollectType $type, array $options): void`**. The base resolver calls this for any `CollectType` it doesn't recognize. Use it to wire up your own enum-based collect types alongside the built-in ones.
- **Override the standard collect handlers** (`requests()`, `console()`, `jobs()`, `cache()`, `views()`, etc.) to swap in framework-specific recorders or middleware. Call `parent::xxx($options)` first if you only want to add to the base behavior, or skip the parent call to replace it.

```php
namespace YourFramework\Flare\Support;

use Spatie\FlareClient\Contracts\FlareCollectType;
use Spatie\FlareClient\Support\CollectsResolver as BaseCollectsResolver;
use YourFramework\Flare\Enums\YourFrameworkCollectType;
use YourFramework\Flare\FlareMiddleware\AddYourFrameworkInformation;
use YourFramework\Flare\Recorders\YourFrameworkRecorder;

class CollectsResolver extends BaseCollectsResolver
{
    protected function handleUnknownCollectType(FlareCollectType $type, array $options): void
    {
        match ($type) {
            YourFrameworkCollectType::FrameworkInfo => $this->frameworkInfo($options),
            default => null,
        };
    }

    protected function frameworkInfo(array $options): void
    {
        $this->addMiddleware(AddYourFrameworkInformation::class);
        $this->addRecorder(YourFrameworkRecorder::class, $options);
    }

    protected function requests(array $options): void
    {
        // Swap in a framework-specific request middleware while keeping the
        // base RequestRecorder / RoutingRecorder / ResponseRecorder wiring.
        $options['middleware'] ??= \YourFramework\Flare\FlareMiddleware\AddRequestInformation::class;

        parent::requests($options);
    }
}
```

Register the custom resolver through the `collectsResolver` config option so `FlareProvider` instantiates yours instead of the base class:

```php
$config->collectsResolver = YourFramework\Flare\Support\CollectsResolver::class;
```

### Customizing `FlareProvider`

`FlareProvider` accepts four optional constructor arguments that control how it interacts with your framework. The defaults work for a plain PHP app, but most framework integrations override at least one of them.

```php
$provider = new FlareProvider(
    config: $config,
    container: $container,
    registerRecorderAndMiddlewaresCallback: $registerRecorderAndMiddlewaresCallback,
    isUsingSubtasksClosure: $isUsingSubtasksClosure,
    gracefulSpanEnderClosure: $gracefulSpanEnderClosure,
    disableApiQueue: $disableApiQueue,
);
```

#### `registerRecorderAndMiddlewaresCallback`

Signature: `Closure(Container, class-string<Recorder>, array $config): void`.

Called once per recorder and once per middleware during `register()`, with the container, the class name, and the option array from the resolver. The default callback binds the class as a singleton and passes `['config' => $config]` so the autowiring layer can pull the rest of the constructor parameters from the container.

Override it when your container needs a different binding API. The Laravel integration uses contextual binding (`$app->when($class)->needs('$config')->give($config)`) and also invokes a static `registered($container, $config)` hook on the recorder if it exists, so third-party recorders can run additional setup at register time.

```php
$registerRecorderAndMiddlewaresCallback = function ($container, string $class, array $config) {
    $container->singleton($class);
    $container->when($class)->needs('$config')->give($config);

    if (method_exists($class, 'registered')) {
        $class::registered($container, $config);
    }
};
```

#### `isUsingSubtasksClosure`

Signature: `Closure(): bool`.

Returns `true` when the lifecycle should run in subtask mode (one persistent process handling many units of work) instead of starting a fresh root trace per request. `Lifecycle` calls this once at construction and stores the result on the readonly `usesSubtasks` property.

This is the only switch that flips subtask mode on, so every long-running runtime your integration supports needs to be detected here. 

#### `gracefulSpanEnderClosure`

Signature: `Closure(Span $span): bool`.

`Tracer::gracefullyEndSpans()` runs from the registered shutdown function and force-closes any span still open at the end of the request. The closure decides, per span, whether that span should actually be closed. Return `true` to let the tracer end it (the default), or `false` to leave it open.

This matters for spans that your integration closes itself in a later phase. Laravel keeps `php_application`, `php_request`, and `php_application_terminating` open across the shutdown handler so `Lifecycle::terminated()` can close them with the correct end time:

```php
use Spatie\FlareClient\Enums\SpanType;

$gracefulSpanEnderClosure = function (Span $span) {
    $type = $span->attributes['flare.span_type'] ?? null;

    if ($type === null) {
        return true;
    }

    return $type !== SpanType::Application
        && $type !== SpanType::Request
        && $type !== SpanType::ApplicationTerminating;
};
```

#### `disableApiQueue`

Signature: `bool`.

Disables the API request queue inside the `Api` client. With the queue off, errors and logs are sent to Flare immediately on each call instead of being batched and flushed later. Use this for one-shot processes that won't outlive the queue (REPLs, short test commands, or anywhere you need telemetry to land synchronously). Laravel sets it to `true` when running under `php artisan tinker`.

### What you get back

After `boot()` returns, resolve `Flare::class` from the container to get a fully constructed `Flare`. The building blocks above are exposed as public readonly properties: `$flare->lifecycle`, `$flare->tracer` (plus `$flare->backTracer`), `$flare->logger` (also `$flare->log()`), `$flare->reporter`, and `$flare->sentReports`.

Each recorder is a method on `$flare`: `request()`, `routing()`, `command()`, `job()`, `queue()`, `query()`, and so on. They return `null` when the matching recorder is disabled, so call them with the null-safe operator (`$flare->request()?->recordStart(...)`).

## Wiring the application lifecycle

A framework integration is responsible for telling Flare when the application starts, registers, boots, terminates. The full sequence of methods is documented in [Application lifecycle](/docs/php/general/application-lifecycle); here is the typical wiring for a framework that runs through a single request and then exits.

```php
use Spatie\FlareClient\Time\TimeHelper;

$startTime = TimeHelper::phpMicroTime($_SERVER['REQUEST_TIME_FLOAT']);

$flare->lifecycle->start(
    timeUnixNano: $startTime,
    traceparent: $request->getHeader('traceparent'),
);

$flare->lifecycle->register();
// ... register services in your container ...
$flare->lifecycle->registered();

$flare->lifecycle->boot();
// ... boot service providers ...
$flare->lifecycle->booted();

// ... handle the request ...

$flare->lifecycle->terminating();
// ... run terminate callbacks ...
$flare->lifecycle->terminated();
```

For a long-running process that handles many units of work (a queue worker, an Octane-style HTTP server), see the [subtask mode](#queue-workers-and-long-running-processes) section below.

A `register_shutdown_function` is registered automatically during `Lifecycle` construction, so any pending payloads are flushed even if the PHP process exits unexpectedly.

## Recording the request

The request recorder produces the `php_request` container span and sets the entry point to `web`. Pass a `RequestAttributesProvider` to `recordStart()`, or use one of the helpers:

```php
// From a Symfony / Laravel HttpFoundation request
$flare->request()?->recordStartFromSymfonyRequest($request);

// From PHP superglobals
$flare->request()?->recordStartFromGlobals();

// From a custom provider
$flare->request()?->recordStart(new YourRequestAttributesProvider($request));
```

Close the span when you have a response. `recordEnd()` accepts optional request, response, route, and user providers; the helpers cover the common cases:

```php
// From a Symfony / Laravel HttpFoundation response
$flare->request()?->recordEndFromSymfonyResponse($response, $request);

// From a defined status code and body size
$flare->request()?->recordEndFromDefined(statusCode: 200, bodySize: 1024);

// From custom providers
$flare->request()?->recordEnd(
    requestAttributesProvider: new YourRequestAttributesProvider($request),
    responseAttributesProvider: new YourResponseAttributesProvider($response),
);
```

If your framework does not produce Symfony-compatible request and response objects, implement the `RequestAttributesProvider` and `ResponseAttributesProvider` contracts. See [Attribute providers](/docs/php/general/attribute-providers) for the pattern.

## Routing stages

When your framework resolves a route, call the routing recorder. The five-stage breakdown (global before, routing, before, after, global after) is documented in [Routing](/docs/php/data-collection/routing). The important call for entry-point handling is `recordRoutingEnd`, which fills in the `flare.entry_point.handler.*` attributes and triggers the sampler to re-evaluate now that the handler is known, try to resolve this as soon as possible with the least amount of wok:

```php
$flare->routing()?->recordRoutingStart();

// ... route resolution ...

$flare->routing()?->recordRoutingEndFromDefined(
    route: '/users/{userId}',
    method: 'GET',
    handlerName: UsersController::class,
);
```

The `handlerName` is the class (or other identifier) that handles the matched route, typically the controller, closure, or method that the framework resolved. It ends up on the [entry point](#entry-points) which we discuss later.

## Console commands

The command recorder produces the `php_command` container span and sets the entry point to `cli`:

```php
// From a Symfony Console input
$flare->command()?->recordStartFromSymfonyInput($name, $input, $commandClass);

// From the raw CLI invocation (uses $_SERVER['argv'])
$flare->command()?->recordStartFromCli($name, $commandClass);

// From defined values
$flare->command()?->recordStartFromDefined($name, $arguments, $commandClass);

// ... run the command ...

$flare->command()?->recordEnd(exitCode: 0);
```

## Queued jobs

The job recorder produces the `php_job` span and sets the entry point to `queue`. Use it inside your queue worker, around the actual job execution:

```php
$flare->job()?->recordStartFromJob(
    jobName: 'SendWelcomeEmail',
    jobClass: SendWelcomeEmail::class,
    traceparent: $traceparent,
);

try {
    // ... process the job ...

    $flare->job()?->recordEnd();
} catch (\Throwable $exception) {
    $flare->job()?->recordFailed($exception);

    throw $exception;
}
```

The optional `traceparent` argument lets the job continue a distributed trace when the dispatching code stored a [`traceparent`](https://www.w3.org/TR/trace-context/) on the job payload.

For dispatching (the side that puts jobs onto a queue), use `$flare->queue()` to record the dispatch span. Capture the current trace's `traceparent` at dispatch time and store it on the job payload so the worker can continue the trace.

## Queue workers and long-running processes

Long-running processes such as queue workers, Laravel Octane, RoadRunner, or a custom event loop boot once and then handle many units of work. For these processes, you do not want each job or request to start a fresh root trace. Use subtask mode instead:

1. Configure the lifecycle with the `isUsingSubtasksClosure` constructor argument so it runs in subtask mode (this is wired up by your framework integration package, not by user code).
2. Call `start`, `register`, `boot` once, when the worker process boots.
3. For each unit of work, call `startSubtask()` and `endSubtask()`:

```php
while ($task = $queue->pop()) {
    $flare->lifecycle->startSubtask(traceparent: $task->traceparent);

    // Use the regular recorders inside the subtask. The job recorder
    // sets the entry point and the container span for you.
    $flare->job()?->recordStartFromJob(
        jobName: $task->name,
        jobClass: $task->class,
    );

    try {
        $task->handle();
        $flare->job()?->recordEnd();
    } catch (\Throwable $exception) {
        $flare->job()?->recordFailed($exception);
    }

    $flare->lifecycle->endSubtask();
}
```

## Entry points

Every error, trace, and log Flare collects is attached to an entry point. The entry point describes what initiated the work, and Flare uses it both for grouping and for [sampling](/docs/php/performance/sampling) decisions.

The default `EntryPointResolver` sets a base entry point as early as possible. It auto-detects whether the process is running on the web (`web`) or in console (`cli`) and fills in whatever value it can see at that point (the request URL, the command line).

That base is good enough until your framework knows more. Once an HTTP route is matched, a command class is resolved, or a queue worker picks up a job, you have two options:

1. **Overwrite the entry point.** Use this when the real work is a different kind of entry point than the resolver guessed, for example a queued job picked up by a worker process that started in `cli` mode. Resolve the singleton and call `set()` with a fresh `EntryPoint`:

   ```php
   use Spatie\FlareClient\EntryPoint\EntryPoint;
   use Spatie\FlareClient\EntryPoint\EntryPointResolver;
   use Spatie\FlareClient\Enums\EntryPointType;
   use Spatie\FlareClient\Support\Container;

   $resolver = Container::instance()->get(EntryPointResolver::class);
   $resolver->set(new EntryPoint(EntryPointType::Queue, $job::class));
   ```

2. **Extend it with a handler.** Use this when the entry point itself stays correct but you can now describe what handles it (the matched route, the resolved controller, the job class). Implement `EntryPointHandlerProvider` alongside the matching attributes provider; the request, command, and job recorders pick it up and call `setHandler()` for you.

A handler is described by three attributes: `handler.identifier` (a groupable name like `GET /users/{userId}`), `handler.name` (the concrete class or callable, e.g. `UsersController`), and `handler.type` (a framework prefixed kind, e.g. `laravel_controller`). See [Entry points](/docs/protocol/general/entry-points) for the full attribute schema and [Attribute providers](/docs/php/general/attribute-providers) for the provider pattern.

## Senders

A `Sender` is the transport at the bottom of the Flare client. It implements a single method, `post()`, which is responsible for getting an outgoing payload (an error report, a trace, or a batch of logs) to its destination. Everything above it (recorders, middleware, the `Api` queue) builds the payload; the sender just delivers it.

The shipped senders are:

- **`CurlSender`**. The default. Uses libcurl to POST directly to Flare's ingress over HTTPS. Configurable through `timeout` and `curl_options`.
- **`DaemonSender`**. Posts to a local Flare daemon over plain HTTP and lets the daemon batch and forward to Flare in the background. Falls back to a `CurlSender` if the daemon is unreachable. See [Flare daemon](/docs/php/general/flare-daemon).
- **`GuzzleSender`**. A Guzzle based alternative to `CurlSender`, useful when your application already wires Guzzle through dependency injection.
- **`NullSender`**. Drops every payload on the floor. Handy for tests or for environments where you want the rest of the client active without any outbound traffic.

You can read more about the daemon and its tradeoffs on the dedicated [Flare daemon](/docs/php/general/flare-daemon) page.

### Switching senders

Senders are configured on `FlareConfig` through `sendUsing()`:

```php
use Spatie\FlareClient\FlareConfig;
use Spatie\FlareClient\Senders\DaemonSender;

$config = FlareConfig::make('YOUR-API-KEY')
    ->useDefaults()
    ->sendUsing(DaemonSender::class, [
        'daemon_url' => 'http://127.0.0.1:8787',
    ]);
```

The second argument is the configuration array passed into the sender's constructor (the `$config` parameter). Each shipped sender documents its own keys; for example, `CurlSender` accepts `timeout` and `curl_options`, while `DaemonSender` accepts `daemon_url`, `timeout`, `test_timeout`, and `fallback_sender_config`.

### Writing a custom sender

A custom sender is any class that implements `Spatie\FlareClient\Senders\Sender`:

```php
namespace YourFramework\Flare\Senders;

use Closure;
use Spatie\FlareClient\Enums\FlareEntityType;
use Spatie\FlareClient\Senders\Sender;
use Spatie\FlareClient\Senders\Support\Response;

class YourSender implements Sender
{
    public function __construct(
        protected array $config = [],
    ) {
    }

    public function post(
        string $endpoint,
        string $apiToken,
        array $payload,
        FlareEntityType $type,
        bool $test,
        Closure $callback,
    ): void {
        // 1. Serialise $payload (it's a plain array).
        // 2. Send it wherever it needs to go (HTTP, queue, file, ...).
        // 3. Wrap the upstream answer in a Response and hand it to $callback.
        $callback(new Response(code: 202, body: ['ok' => true]));
    }
}
```

Three things to keep in mind:

- **Constructor signature.** Flare's container builds the sender by calling `new YourSender($config)`, where `$config` is the array you passed to `sendUsing()`. Accept it through constructor property promotion and pull keys from it lazily.
- **`$callback` is mandatory.** The `Api` queue uses the response your sender hands to `$callback` to track quota, decide whether to retry, and surface delivery errors. Always call it, even on failure (with a non `2xx` `Response`).
- **`$test` mode.** Test payloads (sent by `php artisan flare:test` or `Spatie\FlareClient\Support\Tester`) bypass batching and expect a synchronous round trip so the caller can show the upstream response. If your transport buffers payloads, fall back to a synchronous send when `$test` is `true`.

Once written, register the custom sender the same way as any built-in sender:

```php
$config->sendUsing(YourFramework\Flare\Senders\YourSender::class, [
    'your_option' => 'value',
]);
```


## Testing your integration

`flare-client-php` ships with a `Tester` helper (`Spatie\FlareClient\Support\Tester`) and fake senders that make it straightforward to assert on the payloads your integration produces. Use them in your integration's test suite to verify that the recorders are being called with the right providers, and that the lifecycle stages are wired up in the correct order.

A reference integration to read alongside this guide is [`spatie/laravel-flare`](https://github.com/spatie/laravel-flare). Its `FlareServiceProvider` shows the full lifecycle wiring (including Octane subtask handling) and the request, routing, command, and job hooks in a real-world setting.

## Need a hand?

Writing an integration touches a lot of moving parts. If you get stuck, run into something that isn't covered here, or want a second pair of eyes on your wiring, send us a message at [support@flareapp.io](mailto:support@flareapp.io). We're happy to help.
