Skip to content

Coming From Laravel

Maat ports Laravel’s mechanisms, not its syntax. Most of the time the two are the same word. This page collects every place they are not — the spellings a Laravel habit will reach for and get wrong.

Each divergence is labelled with its cause, because that tells you whether to expect it to change:

Label Meaning
Dart The Dart language leaves no alternative. It will not change.
Design A deliberate framework decision. It could change, and would be a breaking change if it did.
Hardening Maat is stricter than Laravel on purpose, usually about untrusted input.
Not yet Not in this release.

Quick Reference

Laravel Maat Cause
with('posts') with_(['posts']) Dart — with is a keyword, and there are no varargs
Post::factory() PostFactory() Dart — no reflection, so no convention lookup
->for($user) .belongsTo(user, 'author') Dart — for is a keyword; the relation name is a Design choice, below
->has(Post::factory()) .has(PostFactory(), 'posts') Design — two relations can target the same model, so the name is passed and checked
UserResource::collection($users) resourceCollection(users, UserResource.new, request) Dart — statics are not inherited
User::query() User.query(), which you declare on each model Dart — statics are not inherited
$query->paginate(15) query.paginateRequest(request) Dart — an extension cannot shadow an instance member
fn(User $user) => ... req.bound<User>('user') Dart — no reflection, so the router cannot type-check
$model->name via __get Declared fields, generated by make:model --fields Design — noSuchMethod could emulate it, at the cost of static typing
$user->name = 'Ann'; $user->save(); user = await user.update({'name': 'Ann'}); Design — models are immutable
$user->posts (lazy loads) user.posts().value (throws if not eager-loaded) Design — N+1 must not hide
'casts' => ['at' => 'datetime'] casts: {'at': Cast.dateTime} Design — camelCase spelling is convention; Cast.double_ avoids shadowing the double type, not a language requirement
Schema::create(...) in a migration The injected SchemaBuilder schema parameter Design — the facade misses the transaction
2014_10_12_000000_create_users_table.php m2026_09_02_115436_create_posts_table.dart Design — the file_names lint
PostFactory.php post_factory.dart Design — the file_names lint
Migrations autoloaded from the directory Listed in database/migrations.dart Dart — no autoloading
use SoftDeletes; softDeletes: true and with SoftDeletes<T> Dart — a mixin cannot set a static
use RefreshDatabase; RefreshDatabase() plus three hooks you wire Design — no test-runner dependency
additional() on a collection item is ignored merged into the item, behind its body Design — wrap controls the envelope, not the payload
->paginate() with no page-size limit ?per_page= capped at 100 Hardening
No equivalent ?include= needs an explicit allowlist Hardening
Cartouche resolves a tokenable class name Cartouche.provider('users', (id) => User.query().find(id)) Dart — no reflection in compiled applications
Cartouche SPA cookies and /cartouche/csrf-cookie Personal access tokens only Not yet — sessions and CSRF are a separate subsystem

What the Dart Language Forces

Reserved words take a trailing underscore

with and for are Dart keywords, so neither can be a method name.

await User.def.with_(['posts', 'posts.comments']).get(); // Laravel: ->with('posts')
await PostFactory().belongsTo(user, 'author').create(); // Laravel: ->for($user)

with_ takes a list, because Dart has no variadic parameters: with('a', 'b') becomes with_(['a', 'b']).

Cast.double_ looks like the same rule but is not: double is a built-in identifier, not a keyword, and Cast.double alone compiles clean (verified with dart analyze --fatal-infos). The trailing underscore is a naming convention, kept for the same reason ColumnType.double_ and Blueprint.double_ keep theirs — naming a member double shadows the type inside that class body, so nothing there could declare a bare double afterward — not something the language forces on this particular class today.

belongsTo is not just for renamed — it also names the relation, which is a design choice: see Relations are named, never inferred.

Statics are not inherited

This is the single most common Laravel habit that does not survive the port. In PHP, UserResource::collection() and User::query() resolve through the parent class. In Dart they do not resolve at all.

// Laravel: UserResource::collection($users)
resourceCollection(users, UserResource.new, request);
// Laravel: User::query() — in Maat you declare it, once, per model:
static QueryBuilder<User> query() => def.query();

UserResource.new is a constructor tear-off: the function that builds one resource, passed as a value. It is the port of ::collection’s mechanism, not of its syntax. Do not “fix” this by adding a static to JsonResource; it would not resolve through the subclass.

The same rule is why a model’s configuration lives in one ModelDefinition static (Post.def) rather than in inherited protected $table / $fillable / $casts properties.

No reflection, so nothing is autoloaded

Laravel discovers migrations in a directory, seeders by class name, factories by convention, and commands by scanning. Dart cannot, so every one of them is a list you register:

Thing Registered in
Migrations database/migrations.dartmake:migration appends to it for you
Seeders database/seeders/database_seeder.dart — hand-maintained
Commands lib/app/console/kernel.dart
Service providers bootstrap/app.dart
Factories Nowhere — you construct the class: PostFactory()

Route-model binding reads the request

Laravel resolves the type hint on fn(User $user) at runtime. Maat detects handler arity with type checks, and there is no way to test handler is Function(Request, User) without knowing User at the router level. The binding therefore resolves in middleware and the handler reads the value:

ModelBinding.bind('post', Post.def); // once, in a provider
Route.get('/posts/{post}', (Request request) {
return PostResource(request.bound<Post>('post'));
}).middleware(['bindings']);

The bindings middleware must go on the route or the group, never the global stack. Global middleware runs before the router matches, so no route parameters exist yet and nothing would resolve. Laravel applies SubstituteBindings to the web and api groups for the same reason.

Full details in Routing.

Soft deletes are switched on twice

class User extends Model<User> with SoftDeletes<User> {
static final ModelDefinition<User> def = ModelDefinition<User>(
softDeletes: true,
...
);
}

The definition flag is what installs the global scope; the mixin is what gives the instance its restore() and forceDelete(). A Dart mixin cannot reach into a static, so neither implies the other — and the mixin throws if you forget the flag.

What the Framework Chose

Declared columns, not __get

This one looks like a language rule and is not — the same as the migration file name below. noSuchMethod can emulate __get — a class that overrides it and returns from a map really does intercept an unknown property — and it is not reflection, so it survives dart compile exe intact. The catch is what it costs to use: noSuchMethod only fires through dynamic dispatch. (m as dynamic).name reaches it; m.name on a Model with no declared name field is undefined_getter, a compile-time error. An attribute bag built this way would trade away static checking on every single access, in exchange for not writing the field once.

Maat’s models are typed, immutable classes instead, and pay for the checking with repetition: every column is spelled in four places — the constructor, the field, fromMap and toMap — plus a casts entry and a fillable entry.

That is four chances to disagree, which is exactly why make:model writes them from one source:

Terminal window
maat make:model Post --fields "title:string body:text published:bool published_at:datetime"

Supported --fields types are string, text, int, bool and datetime. An unknown type is an error naming the alternatives, never a silent dynamic field.

Models are immutable

Every method that changes the database returns the persisted instance instead of mutating this:

final user = await User.def.query().create({'name': 'Ann'});
final renamed = await user.update({'name': 'Anne'}); // `user` is unchanged
final saved = await User(name: 'Bob').save(); // the key is on `saved`

This is why createOne() returns a model rather than filling one in, and why a factory’s state() and count() must return a new factory. Factory leaves both abstract precisely because only your subclass can build another of itself:

@override
PostFactory count(int n) => PostFactory(faker: faker, count: n, states: states);

An implementation that mutates and returns this leaks one test’s states into the next, silently — the wrong data still saves. make:factory generates the correct pattern; copy it.

Relations are named, never inferred

Maat could infer the name. ModelDefinition.relations is a public map and every Relation carries the definition it points at, so the framework can match a factory’s child model against the parent’s relations without a shred of reflection. It does not, because the match is not always unique:

// User.def declares both `posts` and `draftPosts`, each a hasMany to Post.
await UserFactory().has(PostFactory().count(3), 'posts').createOne();
await UserFactory().has(PostFactory().count(2), 'draftPosts').createOne();

Inference would be ambiguous exactly where it matters — author and editor both pointing at User is the same shape — and would have to guess or throw at the moment you least want either. So every factory helper takes the relation’s name and checks it against the model’s definition:

await PostFactory().belongsTo(user, 'author').create();

A typo throws immediately, listing the relations that do exist, rather than writing rows with a null foreign key. Laravel gets away with inferring because it guesses a relationship name from the child’s class — which lands on posts and never on draftPosts.

An unloaded relation throws

final users = await User.def.with_(['posts']).get();
users.first.posts().value; // List<Post>, no query
final bare = await User.def.query().get();
bare.first.posts().value; // throws RelationNotLoadedException

Laravel would run a query per user here. Maat refuses, because an N+1 that hides behind property access is found in production, not in review. Load it after the fact with await user.load(['posts']).

Migrations use the injected schema builder, not the facade

class CreatePostsTable extends Migration {
@override
Future<void> up(SchemaBuilder schema) => schema.create('posts', (t) {
t.id();
t.string('title');
t.timestamps();
});
@override
Future<void> down(SchemaBuilder schema) => schema.dropIfExists('posts');
}

The Schema facade exists and works — but it binds to DB.connection, while the migrator runs each migration inside its own transaction and hands up() a builder bound to that transaction. Schema::create inside a migration would run outside the transaction it is supposed to belong to. Use the parameter.

Migration file names carry an m

This one looks like a language rule and is not. The Dart language itself does not require a file name to be an identifier — 2026_09_02_115436_create_posts_table.dart analyzes without complaint on its own. What rejects it is file_names, an info-level lint from package:lints, which every package in this framework — and every project maat new generates — enables via include: package:lints/recommended.yaml. file_names requires a lower_case_with_underscores identifier, and an identifier cannot begin with a digit. Migrations are therefore prefixed:

database/migrations/m2026_09_02_115436_create_posts_table.dart

The m satisfies the lint; without it, the file name would be legal Dart, just not a name maat new ships with the lint turned on. The prefix is cosmetic beyond that: Migration.name — the identity recorded in the migrations table — stays unprefixed, and run order comes from the registry list, not the file name. Generated files are snake_case.dart throughout for the same lint: post_factory.dart, not PostFactory.php.

The request is always passed explicitly

There is no global request() helper. app(), config(), env() and route() exist; the request does not, because it is per-request state and a global would be a footgun in an async server. Anything that shapes output takes a Request:

query.paginateRequest(request);
resourceCollection(items, PostResource.new, request);

paginateRequest also carries its name for a language reason: Dart resolves instance members before extension members, so a paginate(Request) extension would be unreachable behind the query builder’s own paginate({page, perPage}). The ORM is HTTP-unaware and stays that way, so the extension yields the name.

RefreshDatabase is a class, not a trait

final db = RefreshDatabase(migrations: [CreatePostsTable()]);
setUpAll(db.migrate);
tearDown(db.truncate);
tearDownAll(db.close);

It hands the hooks back rather than registering them, so seshat_maat needs no dependency on a test runner. It also deletes rows rather than rolling back a transaction, because Connection exposes only the callback form transaction(body) and a callback scope cannot span package:test’s separate setUp / body / tearDown calls. Ids still restart at 1. See Testing.

The table name comes from a regular-cases inflector

make:model derives the table name from the class: Category becomes categories, BlogPost becomes blog_posts. It handles the regular English cases only, so Person becomes persons, not people. Fix the generated table: line — it is a one-word edit — rather than expecting Laravel’s full inflector.

Where Maat Is Stricter

?per_page= is capped

resolvePerPage clamps the client’s page size to 100 by default. ?per_page=1000000 is a trivial denial-of-service against any paginated endpoint; Laravel leaves the cap to the developer and it is routinely forgotten. An explicit perPage: argument, chosen in server code, bypasses the cap on purpose. See API Resources.

?include= needs an allowlist

There is no allow-everything mode, and depth is capped at 2. A rejected include is a 422 naming what is permitted, never a silent drop. Laravel has no equivalent feature at all; this follows spatie/laravel-query-builder, where the allowlist is the first thing the documentation insists on. See API Resources.

Mass assignment is guarded by default

A model with neither fillable nor guarded is totally guarded and throws MassAssignmentException — the same as Laravel, and worth repeating because make:model without --fields generates an empty fillable: [].

Frontend

Laravel Maat Why
@vite(['resources/css/app.css']) {{ asset('css/app.css') }} khnum’s directives are a closed parser set; helpers are the open extension point
npm run dev maat tailwind --watch no Node dependency
npm run build maat tailwind --minify
package.json, vite.config.js neither exists the Tailwind standalone binary needs no toolchain
content-hashed filenames ?v=<token> query suffix the standalone CLI writes one fixed output filename
public/ served by nginx the PublicFiles global middleware Maat is its own web server
<x-label for="email"> <x-label field="email"> for collides with the @for directive keyword in khnum
@props(['key' => 'value']) @props({"key": "value"}) Maat takes a Dart/JSON map; PHP array syntax does not parse

See Frontend for the full stack.

Known Gaps

Gap Detail
MySQL commits implicitly on DDL A migration that fails part-way leaves earlier statements applied and writes no repository row. Keep MySQL migrations to one DDL statement.
A --table migration’s down() is empty migrate:rollback reports success and changes nothing. Laravel’s --table stub behaves the same way.
Not ported from Eloquent Polymorphic relations, hasManyThrough, accessors/mutators, $hidden/$appends, withCount, cursor pagination, pessimistic locks. Model factories are available — they live in seshat_maat, not the ORM. See Testing.
Faker is minimal Seven generators, by design: a dependency of seshat_maat ships into every production binary. Add the faker package to your own dev_dependencies and call it inside definition().

What Is Unchanged

Worth stating, because the divergences above can make the port look larger than it is. These are spelled exactly as in Laravel: the service container and providers, Route::get-style routing with groups, names and where constraints, middleware and aliases, form requests, the validation rule strings (required|string|max:120, unique:users,email, exists:posts,id), config() and env(), the query builder, relationship declarations, scopes, model events, migrate and db:seed, and the resource vocabulary — when, whenLoaded, additional, wrap, with the one additional() divergence noted above.