Upgrading from 3.x
Version 4.0.1 requires PHP 8.3 or later, builds the router with Solo Router 4 instead of taking it from the container, and throws routing errors instead of answering them with empty responses. Projects on PHP 8.2 can stay on 3.x. Coming from 4.0.0, an earlier state of this release, see from 4.0.0.
Dependencies
solophp/router ^4.0 is now a dependency of solophp/application. Raise your own solophp/router requirement together with the application, or drop it if you do not use the router directly:
composer require "solophp/application:^4.0.1" "solophp/router:^4.0"The router contracts of solophp/contracts, RouterInterface and AllowedMethodsInterface, are no longer used. The container and the emitter still come through WritableContainerInterface and EmitterInterface.
Router
Remove the router bindings
In 3.x a provider registered the router, and Application took it from the container:
// 3.x
$container->set(RouterInterface::class, fn() => new RouteCollector());
$container->set(RouteCollector::class, fn(ContainerInterface $c) => $c->get(RouterInterface::class));Delete both bindings. Application now creates the RouteCollector itself, builds the router and registers it as Solo\Router\Router once the providers have run. A RouteCollector binding left in a provider would give the services that take it an empty collector.
Update routes.php
routes.php receives a Solo\Router\RouteCollector: change callables type-hinted with RouterInterface.
<?php
use Psr\Container\ContainerInterface;
use Solo\Router\RouteCollector;
return function (RouteCollector $r, ContainerInterface $container): void {
$r->get('/users/{id}', [UserController::class, 'show'])->name('users.show');
};Route definitions follow Solo Router 4: for example, duplicate names and paths now throw when the router is built, which happens in the Application constructor. See the router migration guide.
Services that use the router
A service that took RouteCollector or RouterInterface from the container, for example to generate URLs, has to take Solo\Router\Router and use getRouteByName():
// 3.x
public function __construct(private RouteCollector $routes) {}
// 4.0.1
public function __construct(private Router $router) {}Resolve it lazily, in a factory, not inside a provider's register(). See services that need the router.
$app->router
$app->router is a Solo\Router\Router with resolve(), getRouteByName(), getRoutes() and getAllowedMethods(). See the Router API.
RoutingMiddleware
The constructor is (Router $router, ContainerInterface $container): it takes the built router and no longer takes a ResponseFactoryInterface. If you created it yourself, take it from the container instead, where Application registers it. To route with another router, list your own routing middleware: see custom routing middleware.
New: route cache
Config::$routesCachePath keeps the built router in a PHP file, so a request skips registering and compiling routes. See Route Cache.
Routing exceptions
RoutingMiddleware throws RouteNotFoundException, MethodNotAllowedException or InvalidRouteParameterException instead of returning an empty 404, 405 or 400. RouteDispatcher throws RouteNotFoundException instead of returning 404 for a request without a handler. All three extend Solo\Application\Exception\RoutingException.
Keep the empty responses
List RoutingErrorMiddleware right before RoutingMiddleware. The middleware in front of it see its responses as they saw the responses of RoutingMiddleware in 3.x:
<?php
use Solo\Application\RoutingErrorMiddleware;
use Solo\Application\RoutingMiddleware;
return [
CorsMiddleware::class,
ErrorHandlerMiddleware::class,
RoutingErrorMiddleware::class, // new in 4.0
RoutingMiddleware::class,
];Or catch RoutingException in your error middleware to render the errors in your own format: see routing errors.
What changes if you do neither
Application::handle()answers the exceptions outside every middleware. Middleware that add headers or log on the way out, such as CORS, security headers or an access log, no longer see404,405and400responses.- An error middleware that catches every
Throwablenow sees these exceptions as well, unlessRoutingErrorMiddlewareis listed after it. HandleRoutingExceptionfirst, or a404becomes a500. One that takes the status fromgetCode()gets the status right but has to addgetHeaders(), or a405goes out withoutAllow. - A middleware that customized
404or405by checking the status of the response it got back no longer sees them: catchRoutingExceptioninstead.
For example, a middleware that rendered a 404 page:
// 3.x
$response = $handler->handle($request);
return $response->getStatusCode() === 404 ? $this->notFoundPage() : $response;// 4.0.1
try {
return $handler->handle($request);
} catch (RouteNotFoundException) {
return $this->notFoundPage();
}A 404 that a controller returns itself is a response, not an exception, and passes through unchanged.
From 4.0.0
4.0.0, published on 17 October 2026, is an earlier state of this release. It already throws routing errors, so the routing exceptions part of this guide is done, but it takes a solophp/router 3.5 router from the container and supports PHP 8.2. Follow the router part:
- move to PHP 8.3 and require
"solophp/application:^4.0.1"together with"solophp/router:^4.0"; - remove the
RouterInterfaceandRouteCollectorbindings from your providers; - type-hint
routes.phpwithRouteCollectorand make the services that use the router takeSolo\Router\Router.
RoutingMiddleware keeps its two arguments, but the first one is now a Solo\Router\Router instead of a RouterInterface.
Checklist
- PHP 8.3 or later
solophp/application^4.0.1;solophp/router^4.0or removed from yourcomposer.json- No
RouterInterfaceorRouteCollectorbindings in providers routes.phptype-hinted withRouteCollectorand valid for Solo Router 4- Services that need the router take
Solo\Router\Router RoutingErrorMiddlewarelisted beforeRoutingMiddleware, orRoutingExceptionhandled in your error middleware- Optionally,
routesCachePathfor production