nunomaduro/collision-adapter-symfony
Brings Collision's beautiful, readable error reporting to Symfony Console applications. This adapter integrates seamlessly so exceptions and stack traces render with syntax highlighting, clear file references, and helpful context right in your terminal.
Preconditions. You have a Symfony Console application that is not Laravel (if Laravel is in play, Collision already ships transitively — stop). PHP 8.x, Symfony Console 6/7, Collision 7/8. The adapter is dev tooling for internal CLIs, deploy scripts, data pipelines, codegen and workers — not an HTTP error handler.
Install and pin both packages explicitly. The adapter only satisfies Collision's adapter contract; Collision does the rendering. Pin the pair so a composer update cannot drift one past the other:
composer require --dev nunomaduro/collision nunomaduro/collision-adapter-symfony
Register it at the console entrypoint, not in the kernel. Open bin/console, and after the autoloader but before the application runs, register Collision with the Symfony adapter:
#!/usr/bin/env php
<?php
use NunoMaduro\Collision\Provider;
use NunoMaduro\Collision\Adapters\Symfony\SymfonyAdapter;
require __DIR__.'/../vendor/autoload.php';
if (($_SERVER['APP_DEBUG'] ?? false) && stream_isatty(STDERR)) {
(new Provider(new SymfonyAdapter()))->register();
}
// ... build and run the Application
Check the package README against the exact Collision major you pinned — the adapter class name/contract has changed between Collision 6, 7 and 8, and this is the one line you must get right.
Where to look first. Two files, both small: vendor/nunomaduro/collision-adapter-symfony/src/ (the entire adapter is a framework-name/version provider — read it in one sitting, that is also your fork/vendor plan) and vendor/nunomaduro/collision/src/Provider.php (what register() actually installs).
First use case. Throw from a command and compare:
// src/Command/DemoCommand.php
throw new RuntimeException('Something broke while syncing.');
Run it with and without the registration block. The signal that the adapter is wired in — and not just Collision — is the framework metadata line in the render. Stock-looking Collision output with no Symfony line means the adapter is not being used. That is your smoke test; write it down.
Rollback. Delete the block, composer remove --dev nunomaduro/collision nunomaduro/collision-adapter-symfony. Blast radius is one file per entrypoint.
Single guarded bootstrap helper for many entrypoints. Once you have more than one CLI (bin/console, cron scripts, worker boot files), do not copy the block. Put it in bootstrap/cli-errors.php or a tiny autoloaded function and require it from each entrypoint:
// bootstrap/cli-errors.php
if (($_SERVER['APP_DEBUG'] ?? false) && stream_isatty(STDERR)) {
(new Provider(new SymfonyAdapter()))->register();
}
Guard against double registration with a defined() constant or static flag if a bootstrap file can be included twice (common in worker restarts and in test harnesses that boot the app in-process).
Register late — after Symfony's own handler. Collision installs a PHP exception handler via set_exception_handler, which replaces rather than chains. Anything that registers later wins. Modern bin/console with symfony/runtime installs its own handler inside the runtime, so registering before autoload_runtime.php silently does nothing. With the runtime style, register inside the callback you return:
$app = require __DIR__.'/vendor/autoload_runtime.php';
return function (array $context) {
if ($context['APP_DEBUG'] && stream_isatty(STDERR)) {
(new Provider(new SymfonyAdapter()))->register();
}
return new Application($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
Always verify empirically by throwing — never by reading the diff and assuming.
Keep the HTTP path untouched. public/index.php must keep symfony/error-handler. Registering Collision there produces double-rendered traces and, worse, a handler that may mask the original exception.
Gate for CI and log capture. Non-TTY runs (CI runners, | tee, log aggregators) are where the adapter's output turns into ANSI garbage. Collision writes errors to STDERR, so capture 2>&1 in pipelines, and either rely on --no-ansi / NO_COLOR or on the stream_isatty(STDERR) guard above. If you gate on TTY, decide explicitly that CI gets stock Symfony output — that is usually the right trade.
Pair with symfony/var-dumper in the same dev block so dump() output and exception panels look consistent inside Collision's rendering. Do not ship dd().
Custom solution providers. Collision's actionable-fix hints come from a solutions repository contract. If your internal CLIs have a recurring, high-value failure (expired API token, missing mount, wrong APP_ENV), implement the contract for that exception type and register it alongside the provider. Keep hints to one or two lines pointing at the fix command — long hints are noise at 3am.
Test it like output, not like behaviour. Add a functional test that runs a command which throws and asserts on the captured output with ANSI disabled: it contains your exception message, it contains the framework metadata line, and the process exit code is non-zero. That single test covers handler wiring, the adapter, ANSI suppression, and the exit-code regression below.
Long-running workers. Collision only fires on uncaught exceptions; a worker loop that catches and logs per-job will never render through it, which is correct. The handler stays registered for the process lifetime, so keep registration in the outer boot script only.
Exit codes are the one thing that can break your pipelines. Confirm a failing command still exits non-zero after registration (php bin/console demo; echo $?). If you ever see a CI job go green on a thrown exception, remove the block first and investigate second.
Exception masking. If the adapter hands Collision bad metadata, or Collision itself throws while rendering, the original exception is obscured — precisely when clear output matters most. Test deliberately awkward exceptions: empty message, very long message, non-UTF8 binary in the message, one thrown from a shutdown function. Keeping Symfony's handler as the fallback is only possible if you register Collision last, which is why ordering is a runbook item, not an implementation detail.
-vvv and trace length. Verbose mode prints the full trace. On recursive or deeply nested failures this gets long and can be truncated by log collectors. Check one verbose run against your actual log pipeline before rolling out beyond a POC.
No configuration surface is a feature. Collision is configured by code, not by a config schema. If your team starts asking for "collision config," that is a signal you are trying to use the wrong layer — wrap the exception yourself or write a solution provider.
Version coupling is the real maintenance cost. The adapter contract is sensitive to both Collision and Symfony Console majors. Pin both, upgrade them together, and verify the framework line still appears after any bump. With ~44 stars and 0 listed dependents you will be the first to discover breakage, so budget a recurring check rather than assuming it is inert. Keep the "vendor the one class and call set_exception_handler yourself" escape hatch documented.
Framing trap — be honest with your own team. This adapter does not improve rendering. Collision does. The adapter's contribution is Symfony awareness (the framework line) plus consistent wiring. If you pitch it as "Collision for Symfony" and expect a visible change, you will get a false negative on your own POC and conclude it is broken.
Scope it to bin/console-style entrypoints only. Long-running daemons and queue workers that swallow exceptions gain nothing here; they need real error tracking. Collision is human-readable local output, never a production safety net — pair it with Sentry/Bugsnag/OpenTelemetry and say so in the runbook so nobody mistakes pretty traces for alerting.
Deleting the block is the fastest bisect step. When a CLI behaves oddly around error output, comment out the registration block. Rollback is a Composer remove plus one deleted file; treat that cheapness as your debugging lever, not as a last resort.
How can I help you explore Laravel packages today?