DEV Community

Cover image for How to Properly Deprecate API Endpoints in Laravel
Bilal Haidar
Bilal Haidar

Posted on

How to Properly Deprecate API Endpoints in Laravel

API evolution is inevitable. As your application grows, you'll need to phase out old endpoints while maintaining backward compatibility. But what happens when you need to deprecate only one HTTP method of an endpoint while keeping others active?

In this post, I'll show you how to handle a common scenario: deprecating a GET request while keeping the POST request active on the same route, using the same controller.

The Problem

Imagine you have this route setup:

  // modules/Products/Routes/api.php
  Route::get('/products/{id}/export', ProductExportController::class)->name('export');
  Route::post('/products/{id}/export', ProductExportController::class)->name('export');
Enter fullscreen mode Exit fullscreen mode

Both routes use the same controller, but you want to deprecate the GET method while keeping POST active. Simply adding deprecation headers in the controller would affect both methods.

Why Proper Deprecation Matters

Before diving into solutions, let's understand why this matters:

  1. Client Communication: Consumers need advance notice to migrate
  2. Standards Compliance: HTTP has standard headers for deprecation (Deprecation, Sunset)
  3. Monitoring: Track which clients still use deprecated endpoints
  4. Graceful Migration: Avoid breaking production systems

Solution 1: Separate Legacy Controller (Recommended)

The cleanest approach is to create a dedicated controller for the deprecated endpoint that delegates to the main controller while adding deprecation headers.

Step 1: Create the Legacy Controller

  // app/Http/Controllers/ProductExportLegacyController.php
  <?php

  namespace App\Http\Controllers;

  use Illuminate\Http\Request;

  class ProductExportLegacyController
  {
      public function __invoke(Request $request, $id)
      {
          // Delegate to the main controller
          $response = app(ProductExportController::class)($request, $id);

          // Add deprecation headers
          return $response
              ->header('Deprecation', 'true')
              ->header('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT')
              ->header('X-API-Warn', 'This endpoint is deprecated. Use POST /products/{id}/export instead');
      }
  }

Enter fullscreen mode Exit fullscreen mode

Step 2: Update Your Routes

  // Deprecated GET endpoint
  Route::get('/products/{id}/export', ProductExportLegacyController::class)
      ->name('exportLegacy'); // DEPRECATED - Use POST instead

  // Active POST endpoint
  Route::post('/products/{id}/export', ProductExportController::class)
      ->name('export');

Enter fullscreen mode Exit fullscreen mode

Advantages

  • ✅ Clear separation of concerns
  • ✅ Easy to remove when deprecation period ends
  • ✅ No conditional logic in main controller
  • ✅ Self-documenting code

Solution 2: Method-Aware Middleware

If you prefer middleware, create one that checks the HTTP method before adding headers.

Step 1: Create the Middleware

  php artisan make:middleware DeprecateGetMethod

Enter fullscreen mode Exit fullscreen mode
  // app/Http/Middleware/DeprecateGetMethod.php
  <?php

  namespace App\Http\Middleware;

  use Closure;
  use Illuminate\Http\Request;

  class DeprecateGetMethod
  {
      public function handle(Request $request, Closure $next, ?string $sunset = null, ?string $message = null)
      {
          $response = $next($request);

          if ($request->isMethod('GET')) {
              $response->headers->set('Deprecation', 'true');
              $response->headers->set('Sunset', $sunset ?? now()->addMonths(6)->toRfc7231String());

              if ($message) {
                  $response->headers->set('X-API-Warn', $message);
              }
          }

          return $response;
      }
  }


Enter fullscreen mode Exit fullscreen mode

Step 2: Register the Middleware

  // bootstrap/app.php or app/Http/Kernel.php
  protected $middlewareAliases = [
      // ... other middleware
      'deprecate.get' => \App\Http\Middleware\DeprecateGetMethod::class,
  ];

Enter fullscreen mode Exit fullscreen mode

Step 3: Apply to Route

  Route::get('/products/{id}/export', ProductExportController::class)
      ->name('exportLegacy')
      ->middleware('deprecate.get:2026-06-01,Use POST method instead');

  Route::post('/products/{id}/export', ProductExportController::class)
      ->name('export');


Enter fullscreen mode Exit fullscreen mode

Advantages

  • ✅ Reusable across multiple routes
  • ✅ Parameterized sunset date and message
  • ✅ Clean route definitions

Solution 3: Generic Deprecation Middleware

For maximum flexibility, create a middleware that works with any HTTP method.

// app/Http/Middleware/DeprecateEndpoint.php
  <?php

  namespace App\Http\Middleware;

  use Closure;
  use Illuminate\Http\Request;

  class DeprecateEndpoint
  {
      public function handle(Request $request, Closure $next, ?string $methods = null, ?string $sunset = null, ?string $replacement = null)
      {
          $response = $next($request);

          // If no methods specified, deprecate all
          // If methods specified, only deprecate those methods
          $deprecatedMethods = $methods ? explode(',', strtoupper($methods)) : ['*'];

          if ($deprecatedMethods === ['*'] || in_array($request->method(), $deprecatedMethods)) {
              $response->headers->set('Deprecation', 'true');
              $response->headers->set('Sunset', $sunset ?? now()->addMonths(6)->toRfc7231String());

              if ($replacement) {
                  $response->headers->set('Link', "<{$replacement}>; rel=\"alternate\"");
              }
          }

          return $response;
      }
  }
Enter fullscreen mode Exit fullscreen mode

Usage:

  // Deprecate only GET and DELETE methods
  Route::match(['get', 'delete'], '/products/{id}/export', ProductExportController::class)
      ->middleware('deprecated:GET|DELETE,2026-06-01,/api/v2/products/{id}/export');


Enter fullscreen mode Exit fullscreen mode

Understanding Deprecation Headers

Let's break down the HTTP headers we're using:

Deprecation: true

Sunset:

X-API-Warn:

  • Standard: Custom (common practice)
  • Purpose: Human-readable deprecation message
  • Client Action: Display to developers

Link: ; rel="alternate"

Best Practices

1. Document Everywhere

 /**
   * @deprecated This GET endpoint is obsolete as of v2.5.0
   * Will be removed in v3.0.0 (June 2026)
   * Use POST /products/{id}/export instead
   */
  Route::get('/products/{id}/export', ProductExportLegacyController::class)
      ->name('exportLegacy');
Enter fullscreen mode Exit fullscreen mode

2. Log Deprecated Usage

Track who's still using deprecated endpoints:

  public function __invoke(Request $request, $id)
  {
      Log::warning('Deprecated GET /products/export endpoint used', [
          'product_id' => $id,
          'client_ip' => $request->ip(),
          'user_agent' => $request->userAgent(),
      ]);

      // ... rest of controller
  }

Enter fullscreen mode Exit fullscreen mode

3. Communicate the Change

  • Update API documentation
  • Send email notifications to API consumers
  • Add changelog entries
  • Update OpenAPI/Swagger specs

4. Set Realistic Sunset Dates

Give clients enough time to migrate:

  • Minor changes: 3-6 months
  • Major changes: 6-12 months
  • Breaking changes: 12+ months

5. Version Your API

Consider versioned routes for major changes:

  // Old version (deprecated)
  Route::prefix('v1')->group(function () {
      Route::get('/products/{id}/export', ProductExportLegacyController::class);
  });

  // New version
  Route::prefix('v2')->group(function () {
      Route::post('/products/{id}/export', ProductExportController::class);
  });

Enter fullscreen mode Exit fullscreen mode

Monitoring Deprecated Endpoints

Create a simple monitoring system:

// app/Http/Middleware/TrackDeprecatedUsage.php
  public function handle(Request $request, Closure $next)
  {
      $response = $next($request);

      if ($response->headers->has('Deprecation')) {
          event(new DeprecatedEndpointAccessed(
              endpoint: $request->path(),
              method: $request->method(),
              user: $request->user(),
              ip: $request->ip(),
          ));
      }

      return $response;
  }
Enter fullscreen mode Exit fullscreen mode

Then create a dashboard or report:

 // Track usage over time
  DB::table('deprecated_endpoint_usage')
      ->select('endpoint', DB::raw('count(*) as hits'))
      ->where('accessed_at', '>', now()->subDays(30))
      ->groupBy('endpoint')
      ->get();
Enter fullscreen mode Exit fullscreen mode

The Removal Process

When the sunset date arrives:

1. Final Warning Period (1-2 weeks before)

  public function __invoke(Request $request, $id)
  {
      Log::critical('FINAL WARNING: Deprecated endpoint will be removed in 2 weeks', [
          'endpoint' => $request->path(),
          'removal_date' => '2026-06-01',
      ]);

      // ... rest of controller
  }

Enter fullscreen mode Exit fullscreen mode

2. Graceful Removal

  // Return 410 Gone instead of 404 Not Found
  Route::get('/products/{id}/export', function () {
      return response()->json([
          'message' => 'This endpoint was removed on June 1, 2026',
          'replacement' => 'POST /products/{id}/export',
          'documentation' => 'https://docs.example.com/api/v2/products',
      ], 410);
  })->name('exportRemoved');

Enter fullscreen mode Exit fullscreen mode

3. Complete Removal

After a grace period (e.g., 3-6 months), remove the route entirely.

Conclusion

Deprecating specific HTTP methods on shared endpoints requires thoughtful implementation. The separate controller approach offers the best balance of clarity and maintainability, while middleware solutions provide reusability across your API.

Remember:
✅ Use standard HTTP headers
✅ Provide clear migration paths
✅ Give adequate notice
✅ Monitor usage
✅ Document everything

Your API consumers will thank you for the smooth transition!

Top comments (0)