Laravel Version
v13.19.0
PHP Version
8.5.5
Database Driver & Version
No response
Description
When using first-class callable syntax for route definitions (e.g. (new ContainerController)->show(...)), running php artisan route:cache can produce an __PHP_Incomplete_Class error at runtime
for controllers whose action method calls any method on $this.
The chain of failure:
(new SomeController)->show(...) creates a Closure with $this bound to the controller instance.
laravel/serializable-closure v2's ReflectionClosure::isBindingRequired() analyzes the closure body. If the method calls $this->anyMethod(), it returns true, causing the bound controller
instance to be serialized into the route cache as a raw PHP object (O:N:"App\...\SomeController":0:{}).
- When the route is dispatched,
Route::runCallable() deserializes the cached closure using unserialize() with an allowed_classes whitelist that only permits internal serializable-closure
classes (SerializableClosure, Native, Signed, SelfReference, UnsignedSerializableClosure).
- The controller class is not in the whitelist, so PHP produces
__PHP_Incomplete_Class for $this.
- Calling any method on
$this inside the route action throws a fatal Error.
Critically, this only affects controllers whose action method calls $this->method() — controllers that never reference $this in the action body get "this":N; (null) in the serialized form and
are unaffected. This makes the bug appear to affect only specific routes, not all routes.
The error produced:
Error: The script tried to call a method on an incomplete object.
Please ensure that the class definition "App\Http\Controllers...\SomeController"
of the object you are trying to operate on was loaded before unserialize()
gets called or provide an autoloader to load the class definition
The issue lies at the intersection of two components:
laravel/serializable-closure serializes the bound $this object as a raw PHP serialized object, not as a SerializableClosure, so it is subject to the allowed_classes restriction.
Route::runCallable() in laravel/framework restricts unserialize() with allowed_classes that does not (and cannot generically) include user-defined controller classes.
A workaround is to ensure the action method never uses the $this token — e.g. by making any helper methods static and calling them via self::. This causes isBindingRequired() to return
false and the controller is not serialized.
Steps To Reproduce
- Create a controller with a route action that calls a private/public method on
$this:
// app/Http/Controllers/FooController.php
class FooController extends Controller
{
public function show(): string
{
return $this->greeting();
}
private function greeting(): string
{
return 'hello';
}
}
- Register the route using first-class callable syntax:
// routes/web.php
Route::get('/foo', (new FooController)->show(...));
- Run php artisan route:cache.
- Visit /foo.
Expected: Page loads correctly, returns "hello".
Actual: Fatal error:
Error: The script tried to call a method on an incomplete object.
Please ensure that the class definition "App\Http\Controllers\FooController"
of the object you are trying to operate on was loaded before unserialize() gets called.
- Run php artisan route:clear — error disappears.
- Run php artisan route:cache again — error returns.
Root cause confirmation: inspect bootstrap/cache/routes-v7.php after step 3. You will see:
"this";O:35:"App\Http\Controllers\FooController":0:{}
The controller is embedded as a raw PHP serialized object. Change greeting() to static and call it as self::greeting() — after re-caching, the entry becomes "this";N; and the error is gone.
Laravel Version
v13.19.0
PHP Version
8.5.5
Database Driver & Version
No response
Description
When using first-class callable syntax for route definitions (e.g.
(new ContainerController)->show(...)), runningphp artisan route:cachecan produce an__PHP_Incomplete_Classerror at runtimefor controllers whose action method calls any method on
$this.The chain of failure:
(new SomeController)->show(...)creates a Closure with$thisbound to the controller instance.laravel/serializable-closurev2'sReflectionClosure::isBindingRequired()analyzes the closure body. If the method calls$this->anyMethod(), it returnstrue, causing the bound controllerinstance to be serialized into the route cache as a raw PHP object (
O:N:"App\...\SomeController":0:{}).Route::runCallable()deserializes the cached closure usingunserialize()with anallowed_classeswhitelist that only permits internal serializable-closureclasses (
SerializableClosure,Native,Signed,SelfReference,UnsignedSerializableClosure).__PHP_Incomplete_Classfor$this.$thisinside the route action throws a fatalError.Critically, this only affects controllers whose action method calls
$this->method()— controllers that never reference$thisin the action body get"this":N;(null) in the serialized form andare unaffected. This makes the bug appear to affect only specific routes, not all routes.
The error produced:
The issue lies at the intersection of two components:
laravel/serializable-closureserializes the bound$thisobject as a raw PHP serialized object, not as aSerializableClosure, so it is subject to theallowed_classesrestriction.Route::runCallable()inlaravel/frameworkrestrictsunserialize()withallowed_classesthat does not (and cannot generically) include user-defined controller classes.A workaround is to ensure the action method never uses the
$thistoken — e.g. by making any helper methodsstaticand calling them viaself::. This causesisBindingRequired()to returnfalseand the controller is not serialized.Steps To Reproduce
$this:Expected: Page loads correctly, returns "hello".
Actual: Fatal error:
Error: The script tried to call a method on an incomplete object.
Please ensure that the class definition "App\Http\Controllers\FooController"
of the object you are trying to operate on was loaded before unserialize() gets called.
Root cause confirmation: inspect bootstrap/cache/routes-v7.php after step 3. You will see: