symfony/phpunit-bridge
PHPUnit Bridge adds utilities on top of PHPUnit, with a focus on managing deprecation notices in your test suite. It helps you track, report, and fix deprecations so your code stays compatible with future Symfony and PHP versions.
symfony/phpunit-bridge is a dev-only package. In a Laravel app it won't replace phpunit/phpunit (Laravel pins that), but it sits alongside it to give you three things Laravel doesn't ship: structured deprecation accounting, deprecation assertion helpers, and ClockMock/DnsMock for non-deterministic code.
composer require --dev symfony/phpunit-bridge
Pick the bridge major that matches your PHP runtime: ^5.4 for PHP 7.x, ^6.4/^7.x for PHP 8.1–8.3, ^8.x for PHP 8.4+. Mixing an 8.x bridge into an 8.1 app fails at install time, not at test time.
Where to look first: phpunit.xml / phpunit.xml.dist. On PHPUnit 10+ the bridge attaches itself as a PHPUnit extension, not via a bootstrap file:
<phpunit>
<extensions>
<bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension"/>
</extensions>
</phpunit>
Then run your normal command (php artisan test or vendor/bin/phpunit) and read the new footer:
Remaining direct deprecations 12
Remaining indirect deprecations 340
Remaining self deprecations 3
First use case: before a Laravel major upgrade (11 → 12) or a PHP bump, this footer tells you which deprecations you wrote (self), which come from packages you call directly (direct), and which are buried transitively (indirect). You fix self, upgrade or patch to clear direct, and set a threshold for the rest — instead of scrolling thousands of identical PHP Deprecated: lines.
Gate CI on your own deprecations only. SYMFONY_DEPRECATIONS_HELPER is the control surface:
# .github/workflows/tests.yml
- run: php artisan test
env:
SYMFONY_DEPRECATIONS_HELPER: "max[self]=0&max[direct]=0&max[indirect]=999"
The classic ratchet is max[total]=0 for a clean app, then loosen to max[self]=0 once your vendor tree lags. Values: disabled, verbose=1 (prints the triggering stack trace — essential for anonymous deprecations), quiet[]=indirect to drop a bucket from the output, and max[...]=N per bucket. For a legacy suite, point the helper at a generated baseline JSON file so existing noise doesn't block the build while new deprecations still fail it.
Assert expected deprecations instead of ignoring them. When you deliberately call a deprecated path (a legacy driver, a shim for a package you don't control), use the bridge traits:
use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait;
class LegacyPaymentGatewayTest extends TestCase
{
use ExpectUserDeprecationMessageTrait;
public function test_legacy_charge_path_warns(): void
{
$this->expectUserDeprecationMessage('Since app 2.0: charge() is deprecated, use PaymentIntent.');
(new LegacyPaymentGateway)->charge(100);
}
}
ExpectDeprecationTrait::expectDeprecation() covers trigger_error(..., E_USER_DEPRECATED); ExpectUserDeprecationMessageTrait matches on the message and integrates with the bridge collector, so the assertion consumes the notice instead of counting it toward the threshold — which is what makes max[self]=0 sustainable.
Freeze time for code that calls globals. Laravel code should use Carbon::setTestNow(), but third-party code and old helpers often call time()/date()/strtotime() directly. ClockMock overrides those functions in a registered namespace:
use Symfony\Bridge\PhpUnit\ClockMock;
/**
* @group time-sensitive
*/
class TrialExpiryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
ClockMock::register(TrialExpiry::class); // must precede first load of the class
ClockMock::withClockMock(1700000000);
}
public function test_trial_expires_after_14_days(): void
{
$this->assertFalse((new TrialExpiry)->isActive());
}
}
On PHPUnit 10+ use #[Group('time-sensitive')] instead of the annotation — the group is what triggers the mock lifecycle and the cleanup between tests. DnsMock::register()/withMockingEnabled() is the equivalent for gethostbyname(), dns_get_record(), etc.; use it with @group dns-sensitive for any test that would otherwise hit the network.
Scope it to one suite, not the whole app. If your full suite is noisy, add the SymfonyExtension only to a dedicated phpunit-deprecations.xml and run it as a separate CI job (vendor/bin/phpunit -c phpunit-deprecations.xml). That keeps php artisan test --parallel fast and your deprecation report actionable.
simple-phpunit is not your binary in a Laravel app. vendor/bin/simple-phpunit downloads and manages its own PHPUnit copy (default dir bin/.phpunit, cacheable in CI via SYMFONY_PHPUNIT_DIR). That copy won't match Laravel's pinned phpunit/phpunit, and php artisan test resolves vendor/bin/phpunit, not simple-phpunit. Use the SymfonyExtension route and skip simple-phpunit unless you're pinning PHPUnit versions across a matrix with SYMFONY_PHPUNIT_VERSION.Illuminate\Foundation\Bootstrap\HandleExceptions calls set_error_handler() when the app boots, once per test. Deprecations raised during application bootstrap may be attributed to Laravel's handler rather than the bridge's collector. Assertions inside the test body are reliable; don't trust the bucket counts alone for framework-internal deprecations — run one test with SYMFONY_DEPRECATIONS_HELPER=verbose to confirm attribution.self classification depends on project-root detection. The bridge decides what counts as "your code" from the Composer autoloader paths. Symlinked vendor/ directories (common with path repositories, DDEV, or Docker bind mounts) historically broke this — fixed in 7.4.17/8.1.5. If local counts differ wildly from CI, that's the first thing to check.ClockMock::register(Foo::class) only works if Foo's calls to time() resolve to the bridge's namespaced override at first compilation. Calling it after the class was autoloaded silently does nothing — no error, just a flaky test.php artisan test --parallel (ParaTest) runs workers in separate processes; the bridge's per-run counters don't aggregate meaningfully, and threshold breaches can surface as worker exit codes without the nice summary. Run the deprecation job serially.<env> in phpunit.xml vs real env. Put SYMFONY_DEPRECATIONS_HELPER in the <php><env name="..." value="..."/></php> block for local runs, but set it as a genuine environment variable in CI (Docker/Kubernetes env, GitHub env:) — that path is unambiguous regardless of PHPUnit's force handling.DebugClassLoader::enable() in a bootstrap if you maintain a package that extends Symfony classes — it warns on PSR-4 mismatches and on overriding @final/@internal methods, catching breakage that would otherwise appear only on the next Symfony minor. For programmatic control, DeprecationErrorHandler::register($config) accepts the same string you'd pass to the env var; useful in a custom tests/bootstrap.php when you need different thresholds per directory.How can I help you explore Laravel packages today?