deploydoubles / checks-php
Generates the Deploy Doubles deploy report (/.well-known/deploy-report) for PHP applications.
Requires
- php: >=8.2
- ext-json: *
Requires (Dev)
- doctrine/dbal: ^4.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/messenger: ^7.4 || ^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Generates the deploy report — GET /.well-known/deploy-report — for PHP applications, so a pipeline, an agent or a monitor can tell whether a deploy actually works.
Laravel, Symfony and plain PHP (no framework) are supported.
This package is developed in the
deploydoubles/doublesmonorepo. Its own repository is a read-only mirror; open issues and pull requests in the monorepo.
Install (Laravel)
composer require deploydoubles/checks-php
The service provider is auto-discovered. It:
- serves
GET /.well-known/deploy-report(no session, no cookies); - schedules
php artisan deploy-report:runevery minute, which runs the checks and stores their results instorage/deploy-report/.
Make sure the app's scheduler runs (php artisan schedule:work, or schedule:run from cron every minute) and that storage/ is shared by the web process, the queue workers and the scheduler, and kept across releases (a persistent or shared directory, such as Deployer's shared_dirs).
Install (Symfony)
composer require deploydoubles/checks-php
Register the bundle in config/bundles.php (there is no Flex recipe yet):
DeployDoubles\Checks\Symfony\DeployReportBundle::class => ['all' => true],
The bundle:
- answers
GET /.well-known/deploy-reportfrom a request listener that runs before routing and the firewall — no route import, no access rule, no session; - adds
php bin/console deploy-report:run, which runs the checks and stores their results invar/deploy-report/. Run it every minute — from cron (* * * * * php bin/console deploy-report:run) or from your own scheduler; - sends the queue probe as a Messenger message on the
asynctransport (probe_transport), so a runningmessenger:consume asyncworker answers it. RouteDeployDoubles\Checks\Symfony\ProbeMessageto that transport too, so tools that read your routing see the worker.
Configure it in config/packages/deploy_report.yaml:
deploy_report: tier: public # a literal; an %env()% value is rejected at compile time checks: database: { expected: postgres } queue: { expected: database } # doctrine transport; redis, amqp, ... queue.release: {} scheduler: {} scheduler.release: {} storage: {} env: { required: [APP_SECRET] }
The tier guard has a limit: it rejects %env()% placeholders, which is what a YAML or XML file can use to read the environment. A configuration written in PHP (config/packages/deploy_report.php) that calls getenv() or reads $_SERVER while the container is built hands the bundle a plain "full" or "public", which it cannot tell apart from a literal. Keep deploy_report in YAML with tier written out; the reference doubles' tests check exactly that, in their committed YAML only.
token and run_id default to DEPLOY_REPORT_TOKEN and DEPLOY_RUN_ID. Without checks, they are inferred from the container (a Doctrine connection, the probe transport). store_path and storage_marker_path default to var/deploy-report and var/storage/deploy-report; share both between the web process, the worker and the cron job, and keep them across releases (a persistent or shared directory, such as Deployer's shared_dirs). The database check uses the Doctrine DBAL connection (connection, default default) and, when DoctrineMigrationsBundle is installed, reports pending migrations.
Install (plain PHP)
composer require deploydoubles/checks-php
Commit a deploy-report.php in the app root (the directory that holds vendor/) that returns an array:
<?php return [ 'tier' => 'public', // a literal; never read it from the environment 'checks' => [ 'database' => ['expected' => 'mysql'], 'scheduler' => [], 'scheduler.release' => [], 'storage' => [], 'env' => ['required' => ['APP_ENV']], ], // Your own connection, as a closure: called by the cron runner only, never per request. 'database' => fn (): PDO => new PDO(/* ... */), ];
Route the report from your front controller, and run the checks from cron:
if (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) === '/.well-known/deploy-report') { DeployDoubles\Checks\serve(); return; }
* * * * * cd /path/to/app && php vendor/bin/deploy-report-run
Cron jobs usually get no environment variables: if your app reads them from a .env file, load it at the top of deploy-report.php. token and run_id default to DEPLOY_REPORT_TOKEN and DEPLOY_RUN_ID; store_path and storage_marker_path default to storage/deploy-report and storage/app/deploy-report, which must be shared by the web server and cron, and kept across releases. There is no queue check without a framework.
Configure (Laravel)
php artisan vendor:publish --tag=deploy-report-config
| Key | Default | Meaning |
|---|---|---|
tier |
'public' |
Keep 'public'. Only reference apps commit 'full'. A literal — never read it from env(). |
token |
env('DEPLOY_REPORT_TOKEN') |
Bearer token (≥ 32 characters) that unlocks the full tier |
run_id |
env('DEPLOY_RUN_ID') |
Optional per-deploy run ID; the report answers whether the verifier's Deploy-Run-Id header matches |
checks |
null (inferred) |
Declare checks and what you expect, e.g. 'database' => ['expected' => 'mysql'] |
Inference reads your database, cache and queue configuration. A committed declaration is better: inference cannot see a silent fallback caused by a missing environment variable — the configuration is the fallback.
What it checks
database (connects, no pending migrations, write then read), cache (round trip, expected store), queue and queue.release (a probe job each minute, answered by a worker on the same commit), scheduler and scheduler.release (fresh heartbeat, one release per minute), storage (writable marker), env (required names present).
Security
- Without a valid token the report says only pass or fail per check.
- With
Authorization: Bearer <DEPLOY_REPORT_TOKEN>it adds versions asengine major.minor, the commit, fixed error codes and hints. - It never contains exception text, hostnames, IP addresses, usernames, paths, connection strings or environment values. Errors are mapped to fixed codes by type and SQLSTATE; exception messages are never read.
- A request only reads stored results. It never runs a check, dispatches a job or touches your database or cache.
Known limitations
- Laravel's
schedule:runreads the cache before it runs anything. When the cache server is down, the scheduler cannot run, so the report showsscheduler: failrather thancache: fail.
Licence
MIT.