Every package README opened the same way for four years: run composer require, then add this line to the providers array, then add this line to the aliases array. 5.5 is LTS, and it is the release where those two steps disappear. What replaces them is worth understanding before it surprises somebody at three in the morning.
The symptom
A package installed on a Friday worked locally and did nothing in staging. The composer install had run, the class existed, and the facade resolved to a container binding that was never made — because the deploy had used a config file from a branch that predated the manual registration.
$ php artisan tinker
>>> Excel::create('report');
IlluminateContractsContainerBindingResolutionException with message
'Target [MaatwebsiteExcelExcelServiceProvider] is not instantiable.'
$ git log --oneline -3 -- config/app.php
8f2a1c9 add excel provider and alias ← on the feature branch
1d4b7e2 add redis session driver
9c3e5a1 initialTwo lines in a file that every branch touches is a merge conflict factory, and a merge conflict resolved in a hurry is a line silently dropped. The failure mode is not that registration is hard; it is that it lives somewhere that changes for unrelated reasons.
Why it happens
A service provider tells the framework how to construct a package’s classes, and an alias gives the facade a short name. Neither can be inferred from the fact that a package is present, so both had to be declared — in a file the package cannot write to, by a human who may not have read past the install command.
The information was always available, though. Composer knows exactly which packages are installed, and a package can carry arbitrary metadata in its own composer.json. 5.5 reads it.
The fix
Auto-discovery, and where it reads from
{
"name": "vendor/package",
"extra": {
"laravel": {
"providers": [
"Vendor\Package\PackageServiceProvider"
],
"aliases": {
"Package": "Vendor\Package\Facades\Package"
}
}
}
}
On composer install or update, a script walks the installed packages, collects those blocks and writes them to bootstrap/cache/packages.php. The framework merges that file with the providers array at boot. Nothing is added to config/app.php, which is the point.
$ composer require maatwebsite/excel
...
Discovered Package: maatwebsite/excel
Discovered Package: laravel/tinker
Discovered Package: nesbot/carbon
Package manifest generated successfully.
$ cat bootstrap/cache/packages.php
<?php return array (
'maatwebsite/excel' => array (
'providers' => array ( 0 => 'Maatwebsite\Excel\ExcelServiceProvider' ),
'aliases' => array ( 'Excel' => 'Maatwebsite\Excel\Facades\Excel' ),
),
);Warning
That file is generated and belongs in .gitignore, which the 5.5 skeleton does. An upgraded application that committed bootstrap/cache will ship a stale manifest and get a package that is discovered on one machine and not another — the exact failure this feature exists to remove.
Opting out, which is the part that gets skipped
A package that registers a route, a middleware or a scheduled command now does so without being asked. Usually that is fine. When it is not — a debug bar that must never load in a particular environment, or two packages that both claim a route prefix — the application can refuse specific packages.
{
"extra": {
"laravel": {
"dont-discover": ["barryvdh/laravel-debugbar"]
}
}
}
The wildcard "dont-discover": ["*"] refuses all of it and returns to registering by hand, which is a legitimate choice for an application that wants its boot sequence explicit.
Knowing that * exists is what makes auto-discovery acceptable in a codebase with strict review. The feature is a default rather than a mechanism you are locked into, and saying so in a comment next to the empty array is cheaper than rediscovering it later.
What it does to a debugging session
The cost is that “which providers are actually loaded” is no longer answerable by reading a file. It is answerable, but you have to know where to look, and the first time somebody hits this they will read config/app.php, see nothing, and conclude the package is not installed.
$ php artisan tinker
>>> collect(app()->getLoadedProviders())->keys()
... ->reject(function ($p) { return starts_with($p, 'Illuminate'); })
... ->values();
=> [
"App\Providers\AppServiceProvider",
"Maatwebsite\Excel\ExcelServiceProvider",
]That first snippet earned its place in the project README. It answers the question directly, and it is the thing to run before assuming a package is broken.
Renderable exceptions, which changed more than the release notes suggest
The other 5.5 change worth the upgrade gets one paragraph in the notes. An exception can now decide how it is rendered and whether it is reported, which pulls a growing pile of conditionals out of the handler.
// app/Exceptions/Handler.php — what it was becoming
public function render($request, Exception $e)
{
if ($e instanceof PaymentDeclined) {
return response()->json(['error' => 'declined'], 402);
}
if ($e instanceof QuotaExceeded) {
return response()->json(['error' => 'quota'], 429);
}
return parent::render($request, $e);
}
// what it can be instead
final class PaymentDeclined extends RuntimeException
{
public function render($request)
{
return response()->json(['error' => 'declined'], 402);
}
public function report()
{
// a declined card is not an engineering event
return true; // handled; do not log
}
}
The handler shrinks back to a default, and each exception carries its own answer to both questions next to the thing that throws it. The report() half is the more valuable one in practice — expected failures were filling the error tracker and training everyone to ignore it.
Verifying it worked
$ git diff --stat config/app.php
config/app.php | 14 +-------------
$ rm -rf vendor bootstrap/cache/packages.php
$ composer install --no-dev
$ php artisan route:list | wc -l
84 # same as before the change
$ php artisan config:cache && php artisan route:cache
$ vendor/bin/phpunit
OK (412 tests, 1180 assertions)Deleting the manifest and rebuilding from nothing is the check that matters, because it is what a fresh deploy does. Running the caching commands afterwards catches the other classic failure — a provider that works uncached and breaks when the config is compiled, usually because it read env() outside a config file.
What this costs
A package can now register a route, a command, a middleware or a listener without anyone reading a line about it. Composer’s dependency resolution means that can happen transitively, from a package nobody chose directly. The mitigation is that the discovery output is printed on every install, and reviewing it is a habit worth building before the first surprise.
The second cost is that upgrading is more than a version bump. An application that had every provider listed explicitly now has two sources of truth until somebody removes the manual entries, and a provider registered twice is usually harmless and occasionally not. Doing that cleanup as part of the upgrade, rather than leaving it, is the difference between the feature helping and it being one more thing to check.