Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/Illuminate/Routing/Middleware/DisabledRoute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace Illuminate\Routing\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class DisabledRoute
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return \Symfony\Component\HttpFoundation\Response
*/
public function handle(Request $request, Closure $next): Response
{
$route = $request->route();

if ($route && ($message = $route->getDisabled()) !== null) {
// If it's a callback, execute it
if (is_callable($message)) {
$response = $message($request);

// If callback returns null or false, allow the route to proceed
if ($response === null || $response === false) {
return $next($request);
}

return $response;
}

// For boolean false, allow the route to proceed
if ($message === false) {
return $next($request);
}

// For boolean true or non-empty string, disable the route
$message = is_string($message) && ! empty($message)
? $message
: 'This route is temporarily disabled.';

return response($message, 503);
}

return $next($request);
}
}
39 changes: 39 additions & 0 deletions src/Illuminate/Routing/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,39 @@ protected function staticallyProvidedControllerMiddleware(string $class, string
->all();
}

/**
* Get the disabled status or callback for the route.
*
* @return \Closure|string|bool|null
*/
public function getDisabled()
{
$disabled = $this->action['disabled'] ?? null;

if (is_string($disabled) &&
Str::startsWith($disabled, [
'O:47:"Laravel\\SerializableClosure\\SerializableClosure',
'O:55:"Laravel\\SerializableClosure\\UnsignedSerializableClosure',
])) {
return unserialize($disabled)->getClosure();
}

return $disabled;
}

/**
* Mark this route as temporarily disabled.
*
* @param \Closure|string|bool $messageOrCallback
* @return $this
*/
public function disabled($messageOrCallback = true)
{
$this->action['disabled'] = $messageOrCallback;

return $this->middleware(\Illuminate\Routing\Middleware\DisabledRoute::class);
}

/**
* Specify middleware that should be removed from the given route.
*
Expand Down Expand Up @@ -1397,6 +1430,12 @@ public function prepareForSerialization()
);
}

if (isset($this->action['disabled']) && $this->action['disabled'] instanceof Closure) {
$this->action['disabled'] = serialize(
SerializableClosure::unsigned($this->action['disabled'])
);
}

$this->compileRoute();

unset($this->router, $this->container);
Expand Down
215 changes: 215 additions & 0 deletions tests/Integration/Routing/DisabledRouteTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<?php

namespace Illuminate\Tests\Integration\Routing;

use Illuminate\Support\Facades\Route;
use Orchestra\Testbench\TestCase;

class DisabledRouteTest extends TestCase
{
public function testRouteCanBeDisabledWithDefaultMessage()
{
Route::get('/disabled', function () {
return 'This should not be returned';
})->disabled();

$response = $this->get('/disabled');

$response->assertStatus(503);
$this->assertSame('This route is temporarily disabled.', $response->content());
}

public function testRouteCanBeDisabledWithCustomMessage()
{
Route::get('/custom-disabled', function () {
return 'This should not be returned';
})->disabled('Feature is under maintenance');

$response = $this->get('/custom-disabled');

$response->assertStatus(503);
$this->assertSame('Feature is under maintenance', $response->content());
}

public function testRouteCanBeDisabledWithCallback()
{
Route::get('/callback-disabled', function () {
return 'This should not be returned';
})->disabled(function ($request) {
return response()->json([
'message' => 'Route disabled',
'path' => $request->path(),
], 503);
});

$response = $this->get('/callback-disabled');

$response->assertStatus(503);
$response->assertJson([
'message' => 'Route disabled',
'path' => 'callback-disabled',
]);
}

public function testEnabledRouteWorksNormally()
{
Route::get('/enabled', function () {
return 'This should work';
});

$response = $this->get('/enabled');

$response->assertStatus(200);
$this->assertSame('This should work', $response->content());
}

public function testDisabledRouteWithPostMethod()
{
Route::post('/post-disabled', function () {
return 'This should not be returned';
})->disabled('POST endpoint disabled');

$response = $this->post('/post-disabled');

$response->assertStatus(503);
$this->assertSame('POST endpoint disabled', $response->content());
}

public function testDisabledRouteWithEmptyStringUsesDefaultMessage()
{
Route::get('/empty-message', function () {
return 'This should not be returned';
})->disabled('');

$response = $this->get('/empty-message');

$response->assertStatus(503);
$this->assertSame('This route is temporarily disabled.', $response->content());
}

public function testDisabledRouteWorksWithRouteGroups()
{
Route::prefix('admin')->group(function () {
Route::get('/users', function () {
return 'User list';
})->disabled('Admin area under maintenance');

Route::get('/posts', function () {
return 'Post list';
});
});

$disabledResponse = $this->get('/admin/users');
$disabledResponse->assertStatus(503);
$this->assertSame('Admin area under maintenance', $disabledResponse->content());

$enabledResponse = $this->get('/admin/posts');
$enabledResponse->assertStatus(200);
$this->assertSame('Post list', $enabledResponse->content());
}

public function testDisabledClosureCanBeSerializedForRouteCaching()
{
Route::get('/serialized-closure', function () {
return 'This should not be returned';
})->disabled(function ($request) {
return response()->json([
'message' => 'Serialized closure works',
'method' => $request->method(),
], 503);
});

$routes = Route::getRoutes();
$route = $routes->getByAction('GET', '/serialized-closure');

if (! $route) {
foreach ($routes as $r) {
if ($r->uri() === 'serialized-closure' && in_array('GET', $r->methods())) {
$route = $r;
break;
}
}
}

$this->assertNotNull($route, 'Route not found');

$route->prepareForSerialization();

// Verify the closure was serialized
$this->assertIsString($route->getAction('disabled'));
$this->assertStringStartsWith('O:', $route->getAction('disabled'));

// Verify the getDisabled() method deserializes it correctly
$disabled = $route->getDisabled();
$this->assertInstanceOf(\Closure::class, $disabled);

// Verify the deserialized closure works
$mockRequest = $this->app->make('request');
$response = $disabled($mockRequest);
$this->assertEquals(503, $response->getStatusCode());
$this->assertJson($response->getContent());
}

public function testDisabledStringMessageSerializationDoesNotAffectNormalStrings()
{
Route::get('/normal-string', function () {
return 'This should not be returned';
})->disabled('Normal string message');

$routes = Route::getRoutes();
$route = null;
foreach ($routes as $r) {
if ($r->uri() === 'normal-string' && in_array('GET', $r->methods())) {
$route = $r;
break;
}
}

$this->assertNotNull($route, 'Route not found');

$route->prepareForSerialization();

// Verify normal strings are not affected
$this->assertSame('Normal string message', $route->getAction('disabled'));
$this->assertSame('Normal string message', $route->getDisabled());
}

public function testDisabledBooleanValueSerializationDoesNotAffectBooleans()
{
Route::get('/boolean-true', function () {
return 'This should not be returned';
})->disabled(true);

$routes = Route::getRoutes();
$route = null;
foreach ($routes as $r) {
if ($r->uri() === 'boolean-true' && in_array('GET', $r->methods())) {
$route = $r;
break;
}
}

$this->assertNotNull($route, 'Route not found');

$route->prepareForSerialization();

// Verify booleans are not affected
$this->assertTrue($route->getAction('disabled'));
$this->assertTrue($route->getDisabled());
}

public function testDisabledCallbackReturningNullAllowsRouteToExecute()
{
Route::get('/conditional-disabled', function () {
return 'Route executed';
})->disabled(function ($request) {
// Return null to allow route execution
return null;
});

$response = $this->get('/conditional-disabled');

$response->assertStatus(200);
$this->assertSame('Route executed', $response->content());
}
}
28 changes: 28 additions & 0 deletions tests/Routing/RoutingRouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,34 @@ public function testRouteCanMiddlewareCanBeAssigned()
], $route->middleware());
}

public function testRouteCanBeDisabled()
{
$route = new Route(['GET'], '/', []);
$route->disabled();

$this->assertTrue($route->getAction('disabled'));
$this->assertContains(\Illuminate\Routing\Middleware\DisabledRoute::class, $route->middleware());
}

public function testRouteCanBeDisabledWithCustomMessage()
{
$route = new Route(['GET'], '/', []);
$route->disabled('Custom message');

$this->assertEquals('Custom message', $route->getAction('disabled'));
$this->assertContains(\Illuminate\Routing\Middleware\DisabledRoute::class, $route->middleware());
}

public function testRouteCanBeDisabledWithCallback()
{
$callback = fn ($request) => response('Disabled', 503);
$route = new Route(['GET'], '/', []);
$route->disabled($callback);

$this->assertSame($callback, $route->getAction('disabled'));
$this->assertContains(\Illuminate\Routing\Middleware\DisabledRoute::class, $route->middleware());
}

public function testItDispatchesEventsWhilePreparingRequest()
{
$events = new Dispatcher;
Expand Down