Skip to content

Route cache causes __PHP_Incomplete_Class error for controllers that reference $this via first-class callable syntax #60698

Description

@chescos

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:

  1. (new SomeController)->show(...) creates a Closure with $this bound to the controller instance.
  2. 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:{}).
  3. 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).
  4. The controller class is not in the whitelist, so PHP produces __PHP_Incomplete_Class for $this.
  5. 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

  1. 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';
    }
}
  1. Register the route using first-class callable syntax:
  // routes/web.php
  Route::get('/foo', (new FooController)->show(...));
  1. Run php artisan route:cache.
  2. 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.

  1. Run php artisan route:clear — error disappears.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions