"We'll deprecate v1 eventually" is not a deprecation policy, it's a way of making sure v1 never leaves. Put dates on the enum itself, so the deprecation is a fact your code can act on, not a note in a wiki page nobody reads before shipping the next feature.
// app/Enums/ApiVersion.php (continued)
use Illuminate\Support\Carbon;
public function deprecatedAt(): ?Carbon
{
return match ($this) {
self::V1 => Carbon::parse('2026-09-01'),
self::V2 => null,
};
}
public function sunsetAt(): ?Carbon
{
return match ($this) {
self::V1 => Carbon::parse('2027-01-15'),
self::V2 => null,
};
}
A middleware turns those dates into the standard headers, so any consumer that bothers to check finds out automatically:
// app/Http/Middleware/AddDeprecationHeadersMiddleware.php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Enums\ApiVersion;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpFoundation\Response;
final readonly class AddDeprecationHeadersMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
/** @var ApiVersion $version */
$version = $request->attributes->get('api_version', ApiVersion::default());
$deprecatedAt = $version->deprecatedAt();
if ($deprecatedAt instanceof Carbon && $deprecatedAt->isPast()) {
$response->headers->set('Deprecation', 'true');
$sunsetAt = $version->sunsetAt();
if ($sunsetAt instanceof Carbon) {
$response->headers->set('Sunset', $sunsetAt->toRfc7231String());
}
}
return $response;
}
}
Two dates, not one. deprecatedAt marks the version as officially on notice, still working, but no longer the recommendation. sunsetAt is the date it stops working entirely. The gap between them is the actual migration window, and it should be long enough for a consumer to schedule the work, not so long that nobody feels urgency. Ninety days is a reasonable floor for most integrations; a partner integration wired into someone else's release calendar may need more.
Telling consumers before they find out
Headers only reach consumers who inspect headers, which in practice is almost nobody until the day their integration breaks. A response header is a courtesy for the careful. It is not a notification strategy.
I once watched a partner integration go down for four hours because a version's sunset date passed quietly and nobody on their side had been reading Sunset headers. The fix took ten minutes once someone noticed. Finding out took the whole morning. That gap, ten minutes of fixing versus four hours of not knowing, is the entire argument for pushing the notice instead of waiting for someone to pull it.
This fleet doesn't have that job yet, but it already has both pieces it's built from. DeleteLicenseJob establishes the job shape: ShouldQueue, the single Queueable trait, tries and backoff as plain properties instead of methods. And the fleet already iterates every token holder the same way its maintenance commands do, Bearer::query()->chunkById(), rather than loading the whole table into memory. Put the two together and the notification job writes itself.
// app/Jobs/NotifyBearersOfDeprecationJob.php
declare(strict_types=1);
namespace App\Jobs;
use App\Enums\ApiVersion;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Webplo\ApiInfrastructure\Models\Bearer;
final class NotifyBearersOfDeprecationJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [60, 300];
public function __construct(
public ApiVersion $version,
) {}
public function handle(): void
{
Bearer::query()->chunkById(100, function ($bearers) {
foreach ($bearers as $bearer) {
// dispatch the actual notification (email, webhook, whatever
// this consumer registered) per bearer, here
}
});
}
}
Dispatch it the day a version's deprecatedAt date is set, again partway through the window, and once more a week before sunsetAt. Three notices, spaced out, beat one notice nobody remembers reading. Silence is not neutral here. Silence reads as "nothing changed," right up until it did.