Laravel 8 moved the models and rewrote the factories

Laravel 8 arrived on the eighth of September with a short upgrade guide and three changes that behave very differently under a real application. One is cosmetic, one is a genuine rewrite of every factory in the suite, and one breaks route generation in a way that produces no error at all.

The symptom

$ composer update laravel/framework
$ php artisan route:list | head -3
In RouteCollection.php line 43:
  Target class [PostController] does not exist.

$ vendor/bin/phpunit
Error: Call to undefined method AppModelsPost::factory()

$ php artisan serve
# and this one is silent: every route() call still returns a URL,
# it is just the wrong one on any route defined with a string.

The first two fail loudly, which makes them easy. The third is the one that reaches production: a route defined with the old string syntax still resolves, still generates a URL, and points somewhere unintended if two controllers share a method name.

Why it happens

Two of the three are namespace changes. Models moved to app/Models in the skeleton, and RouteServiceProvider stopped prefixing controller strings with AppHttpControllers — both of which affect where a class is looked up rather than what it does.

The factory change is the substantive one. Factories became classes rather than closures registered in a file, which is a better design and is not a mechanical transformation — a factory with states and relationships has to be rewritten by somebody who understands what it produces.

The fix

The namespace prefix, and the two ways to stop it hurting

// app/Providers/RouteServiceProvider.php
// in 8, this property is GONE from the skeleton:
//   protected $namespace = 'App\Http\Controllers';

// so routes must use the callable-array form
Route::get('/posts/{post}', [PostController::class, 'show']);

// rather than the string form, which now resolves PostController
Route::get('/posts/{post}', 'PostController@show');

The callable-array form is the better one regardless of the version: it is a real class reference, so an editor can navigate it, a static analyser can check it and renaming the controller updates it. That is worth doing on its own merits and the upgrade is a reason to finally do it.

// or keep the old behaviour, in RouteServiceProvider::boot()
Route::middleware('web')
    ->namespace('App\Http\Controllers')
    ->group(base_path('routes/web.php'));

Restoring the property is legitimate for an application with four hundred routes and no appetite for touching all of them in one release. It is a deferral rather than a fix and it should be written down as one, because the property will be removed from the framework eventually and the deferral has no expiry date attached to it.

# the conversion is mechanical enough to script
$ grep -rnoP "'([A-Z]w+Controller)@(w+)'" routes/ | wc -l
412
$ grep -rn "Controller@" routes/       # after: no output
$ php artisan route:list | wc -l
413        # unchanged, which is the assertion

Factories, which are a rewrite rather than a move

// 7: a closure
$factory->define(Post::class, function (Faker $faker) {
    return [
        'title'   => $faker->sentence,
        'user_id' => factory(User::class),
    ];
});

$factory->state(Post::class, 'published', function () {
    return ['published_at' => now()->subDay()];
});
// 8: a class
class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition(): array
    {
        return [
            'title'   => $this->faker->sentence(),
            'user_id' => User::factory(),
        ];
    }

    public function published(): self
    {
        return $this->state(fn () => ['published_at' => now()->subDay()]);
    }
}

The state as a method is the actual improvement: Post::factory()->published() is discoverable and typo-proof, where factory(Post::class)->states('publishd') failed at runtime with an unhelpful message. A suite with fifteen states across eight models gains a great deal from this and pays for it in an afternoon of conversion.

// the model needs the trait — its absence is the
// 'undefined method factory()' error above
class Post extends Model
{
    use HasFactory;
}

// and resolution is conventional: AppModelsPost →
// DatabaseFactoriesPostFactory. so an application that
// did NOT move its models states the mapping once:
Factory::guessFactoryNamesUsing(function (string $model) {
    return 'Database\Factories\' . class_basename($model) . 'Factory';
});

The convention assumes models live in AppModels, which is exactly the directory the upgrade also moved — so an application keeping them in app needs either newFactory on every model or the one resolver in AppServiceProvider::register. The resolver is much better than ninety copies of a method.

Moving models, which is the one to skip

what it is    a change to the SKELETON, not the framework.
              laravel/laravel changed; the framework does not care.

what it costs every model file, every import, every string
              reference in config, every morph map entry, and a
              conflict with every open branch.

what it buys  app/ has fewer files in it.

the upgrade guide says optional. it means optional.

This is the change that generates the most upgrade work and the least benefit, and it is the one people do first because it is the most visible. An application with ninety models and four active branches should decline it and note the decision, or do it in a separate release with nothing else in it.

The morph map is the part that bites if it is done carelessly: a polymorphic relation storing AppPost in a database column keeps storing it, and moving the class without an explicit Relation::morphMap or a data migration produces rows pointing at a class that no longer exists.

Verifying it worked

$ vendor/bin/phpunit
Tests: 1,204 passed

$ php artisan route:list --json | jq -r '.[].action' 
    | grep -c 'Closure|@'
0        # every route is a callable array now

$ grep -rn '->' database/
# (no output)

# and the check that no morph value changed
$ mysql -Nse "SELECT DISTINCT commentable_type FROM comments"
AppPost
AppVideo          # unchanged, because the models did not move

The morph column query is the assertion that the deliberate non-move stayed non-moved, and it is worth keeping as a test rather than a command — a future refactor that relocates a model silently breaks every existing polymorphic row and the suite will not notice unless something checks the stored strings.

Counting the routes before and after is the cheap guard for the string conversion. The regex that rewrites them is close enough to correct to be dangerous: a route with a namespaced string, or one with a method containing a digit, is the case the pattern misses.

What this costs

The factory rewrite is real work with no shortcut, and it touches the test suite rather than the application — which means it can be done incrementally only if both mechanisms coexist, and they do not. The laravel/legacy-factories package exists precisely for this and buys time at the cost of a dependency somebody will forget to remove.

The route namespace deferral is the one that accumulates. Restoring the property means the upgrade completes in a day, and it also means an application on Laravel 9 with a compatibility shim nobody remembers adding — which is the failure mode of every deferral of this shape. Writing the conversion script during the upgrade, even if it is not run until the next release, is what makes the deferral bounded.