Coming From Laravel
Maat is inspired by Laravel’s developer experience; it is not a PHP port. The primary design rule is to preserve a useful outcome while choosing the API and implementation that fit Dart’s type system, null safety, asynchronous runtime, isolates, long-lived process model, and AOT compiler.
A familiar name stays only when its contract remains accurate and idiomatic in Dart. This page collects the intentional differences—the places where copying Laravel syntax or runtime assumptions would make Maat less safe, less typed, or less honest about how Dart works.
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 |
->timezone('America/Chicago') |
.utcOffset(Duration(hours: -5)) |
Dart — no IANA time-zone database in the SDK |
Schedule::job(new Job) |
schedule.call(() => dispatch(Job())) |
Design — the framework core does not depend on the queue package |
php artisan horizon |
no equivalent; your process supervisor runs the workers | Design — Akhet watches workers, it does not spawn them |
| Horizon’s own dashboard and login | Panel(extensions: [akhetVizier()]) |
Design — one panel, one login, one layout |
| Telescope’s per-tag filter closures | config/udjat.dart’s watchers map, opt-in booleans |
Design — no reflection means no closure-based filtering |
| Telescope’s own dashboard and login | Panel(extensions: [udjatVizier()]) |
Design — one panel, one login, one layout |
| Debugbar injects into HTML only, and stores payloads on disk for its AJAX request history | Mekhat always addresses the response with an X-Debugbar-Id header (HTML or not), keeps a bounded in-memory buffer instead of disk storage, adds a maat debugbar terminal tail, and hard-refuses to run in production |
Design — no disk storage to prune, and a header every response carries rather than only the ones with a body to inject into |
'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; sessions and CSRF live in anubis |
Design — one package per concern |
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.dart — make: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
bindingsmiddleware 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 appliesSubstituteBindingsto thewebandapigroups 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:
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 unchangedfinal 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:
@overridePostFactory 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 RelationNotLoadedExceptionLaravel 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.dartThe 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.
Scheduled tasks run in-process
Laravel’s scheduler shells out to a fresh php artisan for every
Schedule::command(), because PHP has no application to reuse. Maat’s
schedule:run is already a booted application, so a fork would pay full
start-up per task and gain nothing: schedule.command() runs in the same
process. schedule.exec() is there for a task that really does need its own.
Two consequences: a scheduled command must not call exit(), and state leaked
into a singleton is visible to the tasks after it in the same run. See
Task Scheduling.
Akhet observes workers; Horizon supervises them
Laravel’s Horizon is two things at once: a queue dashboard and a process manager that starts, stops and auto-scales workers from a config file. Akhet is only the first. Maat applications are long-lived processes already run by something — systemd, a container orchestrator, Forge’s daemons — and a framework package that also spawned workers would be a second, invisible supervisor competing with the real one. Scale workers where you scale everything else. See Akhet.
Following from that: there is no horizon:pause, horizon:continue,
horizon:terminate, or supervisor config block, and no auto-balancing
strategy. Akhet’s history table plus oldest wait on the overview page is what
tells you a queue needs more workers; giving it more is your supervisor’s job.
Akhet is a Vizier extension, not a standalone dashboard
Horizon mounts its own routed dashboard with its own authorization gate. Akhet registers resources, a page and a widget into the admin panel the application already has, so the queue lives beside everything else an operator administers — one login, one layout, one place permissions are decided.
Udjat correlates by batch, not Telescope’s tags
Telescope tags entries so you can filter by request ID or user ID after the
fact, and its watchers can be turned on or off with closures that inspect the
event before deciding whether to record. Udjat’s config is a plain opt-in map
instead: the watchers key in config/udjat.dart records a type only if it
is listed true, and there is no closure to inspect an entry before it
records. The correlating unit is a batch, not a tag — everything one
request or one job caused, opened by the request or job watcher and closed
when that request or job ends. Work belonging to neither is deliberately not
recorded, because there is no meaningful unit to correlate it against. See
Udjat.
Udjat is a Vizier extension, not a standalone dashboard
Telescope mounts its own routed dashboard with its own authorization gate. Udjat registers a resource, a page and a widget into the admin panel the application already has, so entries live beside everything else an operator administers — one login, one layout, one place permissions are decided.
withoutOverlapping() takes no expiry
Laravel’s overlap lock lives in the cache with a 24-hour expiry, because a PHP
process killed mid-task would otherwise leak the lock forever. Maat’s is an
advisory lock on a file, released by the operating system when the process
ends however it ends — so there is nothing to expire and no
schedule:clear-cache equivalent to run. The trade is scope: a cache lock can
be shared across machines and a file lock cannot, which is why onOneServer()
has no counterpart yet.
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.
Filesystem
| Laravel | Maat | Why |
|---|---|---|
Storage::put($path, $contents) |
Storage.disk().put(path, contents) |
No reflection facades; Storage.disk() with no argument is the default disk |
| Flysystem adapters | Disk implementations |
Dart has no Flysystem; the interface is Maat’s own |
Storage::putFile reads into memory |
putStream / readStream on Disk |
Streaming is on the interface from the start, so a large file never becomes a Uint8List |
Storage::setVisibility on S3 |
no-op on S3Disk |
package:minio exposes no object-ACL API; bucket policy is the mechanism |
See Filesystem for the full API.
Media
| Laravel / Spatie | Maat | Why |
|---|---|---|
$post->addMedia($file)->toMediaCollection('images') |
post.media.add(file).toCollection('images') |
Seshat models are immutable; a mutating method on the model would misreport what it did |
registerMediaCollections() override |
static MediaSchema + a mediaSchema forwarder |
No reflection under AOT; a static is readable without an instance, which keeps it enumerable |
getUrl('thumb') 404s until the queue drains |
falls back to the original file’s URL | A page renders one large image rather than a broken one |
responsive_images, manipulations columns |
not present | Out of v1 scope; both are additive migrations |
accepts_mimetypes checked against the file’s detected content |
accepts checked against the client-declared type |
Spatie derives the type from the bytes; senen v1 compares UploadedFile.mimeType, the multipart content-type header the client wrote. It is a declaration check, not a content check, and is exactly as spoofable as the extension — sniff the bytes yourself if you need certainty |
See Media Library for the full API.
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. |
| Horizon metrics snapshots | Not ported. Akhet computes throughput, runtime and failure counts from akhet_job_runs at the moment the page is opened, rather than storing periodic snapshot rows. There is no horizon:snapshot to schedule; akhet:prune is the only maintenance command. |
| Horizon job tags | Not ported. Search job runs by job name, queue or status instead. |
| Telescope tags and starring | Not ported. Udjat entries cannot be tagged, searched by tag, or starred to exempt them from udjat:prune. |
| Telescope’s dump and gate watchers | Not ported. Udjat has eight watchers — request, query, exception, job, event, log, mail, notification — and does not record dd() calls or authorization checks. |
onOneServer() |
Not ported. The overlap lock is a local file, so two machines running the cron entry each run the task. Run schedule:run on one machine, or gate the task with a when() that checks your own database. |
emailOutputTo() |
Not ported. Use onSuccess/onFailure with a sistrum notification, or appendOutputTo a file your log shipper reads. |
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.