symfony/deprecation-contracts
This package provides a single global function, trigger_deprecation(), that triggers silenced deprecation notices so a custom PHP error handler can catch and log them in dev and prod environments. It takes the package name, version, and a printf-style message.
You almost certainly already have it. symfony/deprecation-contracts arrives transitively with symfony/console, symfony/http-foundation, symfony/process, symfony/error-handler — i.e. every Laravel app. Confirm before adding anything:
composer show symfony/deprecation-contracts
composer why symfony/deprecation-contracts # shows which Symfony component pulled it in
Add it explicitly only when your code calls trigger_deprecation() — a transitive dependency reshuffle should never be able to delete a function you depend on:
composer require symfony/deprecation-contracts
Read the whole package first — it's one file. vendor/symfony/deprecation-contracts/function.php is a function_exists() guard wrapping a single @trigger_error("Since $package $version: " . vsprintf($message, $args), E_USER_DEPRECATED). There is no service provider, no facade, no config to publish, no container binding. Everything below follows from that one line.
Then do the part everyone skips: make deprecations visible. Laravel's Illuminate\Foundation\Bootstrap\HandleExceptions special-cases E_DEPRECATED/E_USER_DEPRECATED and routes them to config('logging.deprecations.channel') — which defaults to the null channel, i.e. discarded. Add a real channel in config/logging.php:
'channels' => [
// ...
'deprecations' => [
'driver' => 'daily',
'path' => storage_path('logs/deprecations.log'),
'level' => 'info',
'days' => 14,
'replace_placeholders' => true,
],
],
LOG_DEPRECATIONS_CHANNEL=deprecations
LOG_DEPRECATIONS_TRACE=false # true in local/staging only — it's noisy
First use case — mark one legacy method you already want gone:
public function total(): int
{
trigger_deprecation(
'acme/billing',
'2.4.0',
'The "%s" method is deprecated, call "%s" instead.',
'Invoice::total()',
'Invoice::amount()->get()',
);
return $this->amount()->get();
}
Verify in 20 seconds:
php artisan tinker
>>> trigger_deprecation('acme/billing', '2.4.0', 'Test deprecation from %s', 'tinker');
>>> exit
tail -n 5 storage/logs/deprecations.log
# local.INFO: Since acme/billing 2.4.0: Test deprecation from tinker
Nothing in the log means your channel is misconfigured — that accounts for nearly every "the package does nothing" report.
1. Call it on the first line, not the last. The notice must reach the caller before the legacy path executes, so it still fires if the method later throws or returns early.
2. Centralise the package name and version. You'll write the first two arguments a hundred times; give yourself one seam to edit and one place to swap for Log::warning() later:
namespace App\Support;
final class Deprecations
{
public static function since(string $version, string $message, mixed ...$args): void
{
trigger_deprecation(config('app.slug', 'acme/app'), $version, $message, ...$args);
}
}
// Deprecations::since('2.4.0', 'Config key "%s" is deprecated; use "%s".', 'billing.legacy_currency', 'billing.currency');
3. Deprecate at the resolution point. Config keys, routes, Blade directives, container aliases — anything a user can still use deserves a notice exactly where it is read:
// AppServiceProvider::register()
if (config()->has('billing.legacy_currency')) {
Deprecations::since('2.4.0', 'The "%s" config key is deprecated; use "%s".', 'billing.legacy_currency', 'billing.currency');
}
4. Guard hot paths with once(). A deprecation inside a loop or an accessor floods the log. Laravel 11+ once() is the clean fix — the notice is about log volume, not CPU cost:
once(fn () => Deprecations::since('2.4.0', 'Attribute access on "%s" is deprecated.', static::class));
5. Follow the Symfony convention exactly in packages. Package name from composer.json, version = the release that introduces the deprecation, message phrased "X is deprecated, use Y instead". Consumers grep Since vendor/pkg 2.4.0: to know which upgrade introduced the noise.
6. Test that the notice actually fires. PHP still invokes user error handlers for @-suppressed errors, so a temporary handler catches it:
public function test_legacy_helper_emits_deprecation(): void
{
$captured = [];
set_error_handler(function (int $errno, string $errstr) use (&$captured) {
if ($errno === E_USER_DEPRECATED) {
$captured[] = $errstr;
return true;
}
return false;
}, E_USER_DEPRECATED);
try {
legacyHelper();
} finally {
restore_error_handler();
}
$this->assertCount(1, $captured);
$this->assertStringContainsString('Since acme/billing 2.4.0:', $captured[0]);
}
7. Gate CI on new deprecations. Laravel's base TestCase installs a deprecation-to-exception handler by default, so php artisan test already surfaces them. Harden it further with failOnDeprecation="true" on the <phpunit> root element (PHPUnit 10/11) or --fail-on-deprecation. Reach for withoutDeprecationHandling() sparingly — it's a silencer, and silencers metastasise.
8. Pair the runtime notice with a @deprecated docblock. PHPStan/Psalm and IDEs read the annotation; the function is what actually logs. Do both, one line apart.
9. Use the log as a removal signal. Ship deprecations.log to your aggregator and alert on first occurrence per Since … string (something still calls it) and last occurrence (safe to delete). That converts "we think nobody uses Invoice::total()" into a decision backed by data.
@-suppressed and Laravel's deprecation channel defaults to null. Both must be addressed before you see anything. The @ only zeroes error_reporting() during the call — Laravel's handler deliberately ignores that gate for deprecations — but the null channel drops them outright.symfony/error-handler advice is not your setup. Laravel has its own handler; don't install the Symfony one. And if you ever write a custom handler, never gate on error_reporting() & $level for E_USER_DEPRECATED or the @ will make every notice look disabled.trigger_deprecation() from your root composer.json files autoload. Composer loads dependencies' files before the root package's, so your empty stub runs second → fatal "cannot redeclare". The README's "declare an empty function" trick only works for projects that don't install the package. To silence globally, point the deprecations channel at null.'next', 'upcoming', 'TBD' destroy the whole point of the prefix. Use the version you are about to tag.ArgumentCountError; a %s with no matching argument → ValueError from vsprintf(). Treat deprecation messages like any other formatted string and cover them with a test.config:cache), cached routes, and opcache in FPM will all hide a provider-level notice from you. php artisan optimize:clear before debugging, and prove reachability with a logger() next to the call.trigger_deprecation('acme/billing', '2.4.0', 'Use "%s"', $class) keeps the Since <pkg> <version>: prefix machine-parseable and defers formatting.grep -v 'Since symfony/' isolates yours; in production, route your own calls through App\Support\Deprecations so you can split streams.function_exists()-guarded global emitting a silenced E_USER_DEPRECATED is cheap enough for hot paths. The only real cost is log volume, which is what once() is for.How can I help you explore Laravel packages today?