Docs / Servers
PHP and Laravel
Zero dependencies, and auto-discovered by Laravel, so installing it is most of the integration. PHP has no background thread, and this SDK is built around that rather than around it.
What you need first
A project's DSN. Create a project, then copy it from that project's settings page: it carries the host and the project id, so it is the only thing this SDK needs to be pointed at.
https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>
The key is write-only: it can send events and nothing else, so shipping it inside a mobile app is fine. Read it from your environment rather than committing it.
Install
Add this install as a composer repository, then require the package.
# composer.json
"repositories": [{ "type": "composer", "url": "https://cyclopes.localhost.co.zw/composer" }]
$ composer require cyclopes/sdk
Wire it up
# .env, and on Laravel this is the whole integration
CYCLOPES_DSN=https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>
The environment comes from app()->environment() and the in-app path from base_path(), so vendor/ is excluded and your code is not.
Laravel
Publish the config to change anything:
$ php artisan vendor:publish --tag=cyclopes-config
Add the middleware to the web group so each visitor gets their own session:
// bootstrap/app.php
$middleware->web(append: [\Cyclopes\Integration\CyclopesMiddleware::class]);
Without it every request reports under one process-wide session, and Journeys describes a single enormous visit belonging to nobody.
Plain PHP
use Cyclopes\Cyclopes;
use Cyclopes\Options;
Cyclopes::init(new Options(
dsn: getenv('CYCLOPES_DSN') ?: '',
environment: 'production',
release: 'shop@1.4.2',
appRoot: __DIR__,
));
try {
$cart->total();
} catch (Throwable $e) {
Cyclopes::captureException($e);
}
An empty DSN disables the SDK and is not an error, which is how a local or test environment opts out. A malformed one throws at init: a configuration mistake, and startup is the only moment anybody is watching.
Journeys
Cyclopes::identify(['id' => $user->id, 'plan' => $user->plan]);
Cyclopes::screen('Cart', ['items' => $cart->count()]);
Cyclopes::track('checkout_started', ['value' => $cart->total()]);
Cyclopes::warn('slow query', ['ms' => $elapsed, 'table' => 'orders']);
- identify applies to everything after it, not just its own row. (project, user_id) is one of only two join keys Cyclopes has, and stamping it on the identify row alone would leave the rest of the visit anonymous.
- screen sets where the user is, and every track after it says so. An action recorded without a screen is a line with no scene. For arrivals that are not navigations, call $client->currentScreen('Checkout') without recording an arrival.
Names must be stable and low-cardinality: track('order_paid', ['id' => $id]), never track("order_{$id}_paid"). The second fragments one step into thousands and no journey can match it.
The thing PHP does differently
Every other Cyclopes SDK sends on a background thread. PHP has none: a request runs, the process goes back to the pool, and anything unsent is gone.
- Payloads buffer in memory during the request.
- They go out at the end of it, after fastcgi_finish_request() where the SAPI provides it, so the user already has their response and is not waiting on telemetry.
- The flush is capped in total wall time, because there is no other thread to hand a slow send to.
A long-running worker is the exception. A queue consumer, Octane, RoadRunner or Swoole keeps one process alive across many jobs, where shutdown may be hours away and a scope left behind means job two reports as job one's customer:
// between jobs
Cyclopes::flush();
Cyclopes::resetScope();
If you already have a queue, hand the sending to it and skip the inline post:
new Options(sender: fn (string $url, string $body) => SendTelemetry::dispatch($url, $body));
Sessions
The middleware takes the first of these that exists:
- A propagated header (X-Cyclopes-Session, X-Request-Id, X-Correlation-Id), so a journey can span services.
- The framework session, when one has already started. Never started here: asking for one would turn a stateless API stateful just to measure it.
- A fresh id, so the request is at least self-consistent.
What is captured without asking
captureErrors is on by default and installs three hooks, because PHP has three separate ways for something to go wrong:
- set_exception_handler for uncaught throwables.
- set_error_handler for warnings and notices, respecting error_reporting() so a suppressed warning stays suppressed.
- A shutdown function reading error_get_last(), which is the only way to see a fatal: an exhausted memory limit or a parse error never reaches an exception handler at all.
All three chain rather than replace. Whatever your application installed before still runs, because swallowing it would turn a readable failure into a blank page.
in_app and redaction
PHP is the easy case for in_app: frames carry real filesystem paths, so anything under vendor/ is a dependency and anything outside your app root is not your code. Laravel sets the root from base_path(). Elsewhere, pass appRoot.
new Options(beforeSend: function (array $event): ?array {
unset($event['extra']);
return $event; // or null to drop the event entirely
});
Guarantees
- Never throws into your application. Every public method wraps its body.
- Never delays the response. The flush runs after the response is handed over where the SAPI allows it, and is time-capped where it does not.
- Drops rather than retries on 400, 401, 403, 413 and 429. Only transport errors and 5xx are retried, once.
- Flushes before rotating a session, so buffered items are not relabelled onto the new one.
Then
Naming is the part that is expensive to change once data exists under it, and journeys are what the names are for. Both are the same whatever you wrote the app in:
- Journeys, and the one rule about entry steps people get wrong by hand.
- Naming things, before you have a week of data under a name you regret.
- Coming from Sentry: keep the SDK, change the DSN.
Something here wrong or missing? cyclopes@localhost.co.zw.