The naive version of versioning puts the version in the URL and duplicates the controller for each one: LicenseControllerV1, LicenseControllerV2, two route groups, two everything. It looks disciplined. It is actually a slow-motion fork. Six months in, a bug fix has to be applied twice, and someone always forgets the second one.
Resolve the version once, at the edge, and let one codebase branch on it. This fleet already does exactly that for locale: SetRequestLocaleMiddleware reads a request signal, checks it against a configured list of what's actually supported, and sets state for the rest of the request to use when it matches. Version resolution follows the identical shape.
Start with a backed enum for the vocabulary, the same idiom used for DomainStatus and Dependency:
// app/Enums/ApiVersion.php
declare(strict_types=1);
namespace App\Enums;
/**
* The API versions this service still serves. New integrations should
* default to the highest case; older cases exist only for consumers
* who integrated before it shipped.
*/
enum ApiVersion: string
{
case V1 = 'v1'; // original license response shape
case V2 = 'v2'; // adds `status`
public static function default(): self
{
return self::V2;
}
}
Then a middleware that resolves it, modeled directly on SetRequestLocaleMiddleware's "header, then config default" shape:
// app/Http/Middleware/ResolveApiVersionMiddleware.php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Enums\ApiVersion;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final readonly class ResolveApiVersionMiddleware
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$requested = $request->header('Api-Version');
$version = is_string($requested) ? ApiVersion::tryFrom($requested) : null;
$request->attributes->set('api_version', $version ?? ApiVersion::default());
return $next($request);
}
}
Append it in bootstrap/app.php next to the middleware already doing this exact job for locale:
->withMiddleware(function (Middleware $middleware) {
$middleware->append([
ResolveApiVersionMiddleware::class,
SetRequestLocaleMiddleware::class,
GzipResponseMiddleware::class,
]);
})
$request->attributes is not a new idea here. LogApiRequestsMiddleware already reads a bearer attribute an earlier middleware set, and attributes the log line to it. Version resolution rides the same rail. That's the whole mechanism: one middleware, one enum, one place that knows what "current" means.
Running two versions without two codebases
With the version resolved once at the edge, the controller doesn't fork into two classes. It picks a resource:
// Bad: a second controller for a second version
final class LicenseControllerV2
{
public function index(): Responsable
{
return new CollectionResponse(
data: LicenseResourceV2::collection(
resource: LicenseDTOMapper::toDTOCollection(
licenses: LicenseFacade::licenses(),
),
)
);
}
}
// Good: one controller, one route, a version-aware resource
// app/Http/Controllers/LicenseController.php
public function index(Request $request): Responsable
{
/** @var ApiVersion $version */
$version = $request->attributes->get('api_version', ApiVersion::default());
$resourceClass = $version === ApiVersion::V2
? LicenseResourceV2::class
: LicenseResource::class;
return new CollectionResponse(
data: $resourceClass::collection(
resource: LicenseDTOMapper::toDTOCollection(
licenses: LicenseFacade::licenses(),
),
)
);
}
LicenseResourceV2 extends the original instead of copying it. That only compiles if LicenseResource allows it, and by default it doesn't: resources in this fleet are marked final, the same as everything else. Dropping final from LicenseResource is a deliberate, single exception, an extension point you're choosing to open, not a blanket invitation to leave every class open in case a future version needs it.
// app/Http/Resources/LicenseResourceV2.php
final class LicenseResourceV2 extends LicenseResource
{
public function toArray(Request $request): array
{
return [
...parent::toArray($request),
'status' => $this->resource->status,
];
}
}
One controller. LicenseFacade::licenses(), LicenseDTOMapper, the fetch and mapping logic, exist exactly once. One route. No v1/v2 prefix to keep in sync across two groups. One place a bug gets fixed. The Bad version means every fix to how licenses get fetched has to be copy-pasted into a second controller, and the six-month-later version of that story is always the same: someone patches LicenseControllerV1 and forgets LicenseControllerV2 exists, and a consumer on the "old" version silently keeps the bug that was supposedly fixed weeks ago.
The versioned surface area is the resource, because that's genuinely where the shape differs. Everything upstream of the resource, the query, the DTO, the business logic, doesn't know or care what version asked for it.