Skip to content

Commit eb88c25

Browse files
author
Benyamin Khalife
authored
Merge pull request #117 from webrium/fix/group-state-leak-on-exception
Fix/group state leak on exception
2 parents 9043967 + 1fde828 commit eb88c25

2 files changed

Lines changed: 79 additions & 4 deletions

File tree

src/Route.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,16 @@ public static function group($options, callable $callback): void
210210
self::$prefix = self::$prefix === '' ? $prefix : self::$prefix . '/' . $prefix;
211211
}
212212

213-
call_user_func($callback);
214-
215-
self::$prefix = $oldPrefix;
216-
self::$middlewareIndex = $oldMiddlewareIndex;
213+
try {
214+
call_user_func($callback);
215+
} finally {
216+
// Always restore the previous routing state, even if the callback
217+
// throws. Otherwise an exception inside the group would leave the
218+
// accumulated prefix/middleware behind and leak it into every route
219+
// registered afterwards (state leak).
220+
self::$prefix = $oldPrefix;
221+
self::$middlewareIndex = $oldMiddlewareIndex;
222+
}
217223
}
218224

219225
/**

tests/RouteTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,75 @@ public function testGroupMiddlewareIndexIsAttachedToContainedRoutes(): void
229229
$this->assertSame(0, $this->routes()[0]['middleware']);
230230
}
231231

232+
/**
233+
* Regression: an exception thrown inside a group() callback must NOT leave
234+
* the accumulated prefix behind. Before the fix, the lines that restored
235+
* $prefix ran *after* call_user_func($callback), so a throwing callback
236+
* skipped them and the prefix leaked into every route registered later.
237+
*/
238+
public function testPrefixIsRestoredWhenGroupCallbackThrows(): void
239+
{
240+
try {
241+
Route::group('admin', function (): void {
242+
throw new \RuntimeException('boom inside group');
243+
});
244+
} catch (\RuntimeException $e) {
245+
// The application catches the error and keeps registering routes.
246+
}
247+
248+
// This route is declared OUTSIDE the (failed) group and must not inherit
249+
// the 'admin' prefix.
250+
Route::get('public', fn () => null);
251+
252+
$this->assertSame('/public', $this->routes()[0]['url']);
253+
}
254+
255+
/**
256+
* Regression: an exception thrown inside a group() callback must also
257+
* restore $middlewareIndex to its previous value, otherwise routes declared
258+
* after the failed group are wrongly attached to the group's middleware.
259+
*/
260+
public function testMiddlewareIndexIsRestoredWhenGroupCallbackThrows(): void
261+
{
262+
try {
263+
Route::group(['prefix' => 'secure', 'middleware' => fn () => true], function (): void {
264+
throw new \RuntimeException('boom inside group');
265+
});
266+
} catch (\RuntimeException $e) {
267+
// swallow, application continues
268+
}
269+
270+
// Declared after the failed group: must carry the "no middleware"
271+
// sentinel (-1), not the group's middleware index.
272+
Route::get('open', fn () => null);
273+
274+
$this->assertSame(-1, $this->routes()[0]['middleware']);
275+
}
276+
277+
/**
278+
* Regression: the prefix must also be restored when a *nested* group's
279+
* callback throws, falling back to the parent group's prefix rather than
280+
* the innermost accumulated one.
281+
*/
282+
public function testNestedGroupPrefixIsRestoredToParentWhenInnerCallbackThrows(): void
283+
{
284+
Route::group('admin', function (): void {
285+
try {
286+
Route::group('reports', function (): void {
287+
throw new \RuntimeException('boom in nested group');
288+
});
289+
} catch (\RuntimeException $e) {
290+
// swallow inside parent group, keep registering
291+
}
292+
293+
// Still inside 'admin', after the failed nested group: should be
294+
// prefixed with '/admin' only, never '/admin/reports'.
295+
Route::get('dashboard', fn () => null);
296+
});
297+
298+
$this->assertSame('/admin/dashboard', $this->routes()[0]['url']);
299+
}
300+
232301
public function testRouteOutsideGroupHasNoMiddleware(): void
233302
{
234303
Route::get('open', fn () => null);

0 commit comments

Comments
 (0)