Laravel ⇄ Daravel parity
Laravel 12.x on the left, Daravel 1.x on the right. Every Daravel symbol below is checked against the source by packages/daravel/test/parity_test.dart, so this table cannot drift from the code. async marks calls you await.
Routing#
| Laravel | Daravel | Notes |
|---|---|---|
Route::get($uri, $action) |
router.get(path, handler) |
|
Route::post / put / patch / delete |
router.post / put / patch / delete |
|
Route::match($methods, ...) |
router.match(methods, path, handler) |
|
Route::any(...) |
router.any(path, handler) |
|
Route::redirect($from, $to) |
router.redirect(from, to) |
|
Route::fallback(...) |
router.fallback(handler) |
|
Route::middleware(...)->prefix(...)->group(...) |
router.group((r) {...}, prefix:, middleware:) |
|
Route::apiResource('photos', PhotoController::class) |
router.apiResource('photos', PhotoController()) |
|
Route::resource(...) |
router.resource(name, controller) |
|
->name('users.show') |
.name('users.show') |
|
->middleware(['auth']) |
.middleware(const ['auth']) |
|
->where('id', '[0-9]+') / ->whereNumber('id') |
.where('id', r'\d+') / .whereNumber('id') |
|
->whereUuid('id') |
.whereUuid('id') |
|
public function store(StorePostRequest $request) |
.validates(const StorePostRequest()) |
Validated before the handler; read with request.validated(). |
$request->validated() |
request.validated() |
|
(Scribe / Scramble response annotations) |
.returns(schema, name:, shape:) |
Used by dartisan openapi:generate. |
route('users.show', ['id' => 1]) |
router.url('users.show', {'id': 1}) |
Requests & responses#
| Laravel | Daravel | Notes |
|---|---|---|
$request->validate($rules) |
await request.validate(rules) |
async |
$request->all() |
await request.all() |
async |
$request->input('a.b') |
await request.input<T>('a.b') |
async |
$request->has('key') |
await request.has('key') |
async |
$request->integer('page', 1) |
await request.integer('page', 1) |
async |
$request->boolean('remember') |
await request.boolean('remember') |
async |
$request->float('price') / ->string('name') |
await request.float('price') / .string('name') |
async |
$request->query('page') |
request.query['page'] |
|
$request->query() (ids[]=1&ids[]=2) |
request.queryInput |
Brackets build lists and maps, as PHP parse_str. |
$request->query->getInt('page', 1) |
request.queryInt('page', 1) / queryDouble / queryBool / queryString |
Synchronous: the query string is in memory. |
$request->header('x-id') |
request.header('x-id') |
|
$request->file('avatar') / hasFile('avatar') |
await request.file('avatar') / hasFile('avatar') |
async; Multipart bodies stream to temporary files. |
$file->store('avatars', 'public') / storeAs(...) |
await file.store('avatars', disk: 'public') |
async |
UploadedFile::fake()->image('a.png') |
UploadedFile.fake.image('a.png') |
|
$request->bearerToken() |
request.bearerToken() |
|
$request->ip() |
request.ip |
|
$request->url() |
request.url |
|
$request->route('id') |
request.param<int>('id') |
|
$request->expectsJson() |
request.expectsJson |
|
response()->json($data, $status) |
Response.json(data, status: status) |
|
Blade views ({{ $name }}, {!! $html !!}) |
div([user.name]) / raw(html) from package:daravel/html.dart |
Typed builder, escaped by default; a separate import. |
response()->noContent() |
Response.noContent() |
|
redirect($to) |
Response.redirect(to) |
|
response()->streamDownload(...) |
Response.stream(...) |
|
response()->eventStream(fn () => ...) |
Response.sse(stream) |
Same frames and </stream> end event; adds heartbeats and ends on shutdown. |
new StreamedEvent(event: 'tick', data: $data) |
SseEvent(data, event: 'tick') |
|
Reverb / Pusher channels |
router.ws('/ws/x', (socket, request) {...}) |
A plain WebSocket after the route's middleware, not the Pusher protocol. |
abort(404, $message) |
abort(404, message) |
Without a message the JSON message is empty, as in Laravel. |
class StoreUserRequest extends FormRequest |
final class StoreUserRequest extends FormRequest |
Validation#
| Laravel | Daravel | Notes |
|---|---|---|
Validator::make($data, $rules) |
Validator(data, rules) |
|
$validator->fails() |
await validator.fails() |
async |
$validator->validate() |
await validator.validate() |
async |
ValidationException |
ValidationException |
|
Rule::in([...]) / Rule::unique('users') |
Rules.inList([...]) / Rules.unique('users') |
|
['required', 'email', 'max:255'] |
Rules.required().email().max(255) |
Chained rule objects, checked by the compiler. |
Eloquent#
| Laravel | Daravel | Notes |
|---|---|---|
class User extends Model |
final class User extends Model |
|
protected $table / $fillable / $hidden / $casts |
ModelDefinition<User>(table:, fillable:, hidden:, casts:) |
|
php artisan model:show User |
dartisan model:show User (models listed in bootstrap/models.dart) |
No class discovery: the registry is explicit, like migrations and jobs. |
$model->getMorphClass() |
Relation.morphClassOf(User.definition) |
The morph alias, else ModelDefinition.morphClass (App\Models\User by default). |
User::query() |
User.query() |
|
User::where('a', $b)->first() |
await User.query().where('a', b).first() |
async |
User::find($id) / findOrFail($id) |
await User.query().find(id) / findOrFail(id) |
async; throws ModelNotFoundException |
->firstOrFail() |
.firstOrFail() |
async; throws ModelNotFoundException |
User::create($attributes) |
await User.query().create(attributes) |
async |
User::firstOrCreate / updateOrCreate |
firstOrCreate / updateOrCreate |
async |
->with(['posts']) |
.with_(['posts']) |
with is a reserved word in Dart. |
->withCount('posts') |
.withCount('posts') |
|
->withSum('orders', 'total') / withAvg / withMin / withMax / withExists |
.withSum('orders', 'total') / withAvg / withMin / withMax / withExists |
|
->whereHas('posts', fn) |
.whereHas('posts', (q) {...}) |
|
->whereHas('posts.comments', fn) / whereDoesntHave / orWhereHas |
.whereHas('posts.comments', (q) {...}) |
Nested relations become nested EXISTS, as in Laravel. |
->whereHasMorph('commentable', [Post::class], fn) |
.whereHasMorph('commentable', [Post.definition], (q, type) {...}) |
|
$this->hasOneThrough(Owner::class, Car::class) |
HasOneThrough<Owner>(() => Owner.definition, through: () => Car.definition, firstKey: 'mechanic_id', secondKey: 'car_id') |
|
$this->morphMany(Comment::class, 'commentable') |
MorphMany<Comment>(() => Comment.definition, morphName: 'commentable') |
|
$this->morphOne(Image::class, 'imageable') |
MorphOne<Image>(() => Image.definition, morphName: 'imageable') |
|
$this->morphTo() |
MorphTo(types: [() => Post.definition, () => Video.definition]) |
No reflection: possible targets come from types: or the morph map. |
$this->morphToMany(Tag::class, 'taggable') / morphedByMany |
MorphToMany<Tag>(..., morphName: 'taggable', relatedPivotKey: 'tag_id') / MorphedByMany |
|
Relation::morphMap([...]) / enforceMorphMap([...]) |
Relation.morphMap({'post': () => Post.definition}) / enforceMorphMap |
|
->paginate(15) |
await query.paginate(perPage: 15) |
async; Page and path come from the current request, like Laravel. |
->simplePaginate() / cursorPaginate() |
.simplePaginate() / .cursorPaginate() |
async |
->latest() / oldest() |
.latest() / .oldest() |
|
->withTrashed() / onlyTrashed() |
.withTrashed() / .onlyTrashed() |
|
$model->save() / update() / delete() |
await model.save() / update() / delete() |
async |
$model->restore() / forceDelete() |
await model.restore() / forceDelete() |
async |
$model->isDirty() / wasChanged() |
model.isDirty() / wasChanged() |
|
$model->refresh() / fresh() |
await model.refresh() / fresh() |
async |
$model->load('posts') |
await model.load('posts') |
async |
$model->toArray() / toJson() |
model.toJson() |
|
ModelNotFoundException |
ModelNotFoundException |
Database#
| Laravel | Daravel | Notes |
|---|---|---|
DB::table('users') |
DB.table('users') |
|
DB::select($sql, $bindings) |
await DB.select(sql, bindings) |
async |
DB::statement($sql) |
await DB.statement(sql) |
async |
DB::transaction(fn) |
await DB.transaction(() async {...}) |
async |
DB_CONNECTION=mysql / mariadb |
withDatabaseDriver('mysql', MySqlDriver.fromConfig) |
package:daravel_mysql; registered by new projects. |
DB::connection('legacy')->table('orders') |
DB.table('orders', connection: 'legacy') |
|
'connections' => ['legacy' => ['url' => env('LEGACY_DB_URL')]] |
withDatabaseConnection('legacy', (env) => ConnectionConfig.fromUrl(env.string('LEGACY_DB_URL'))) |
|
DB::connection('legacy')->transaction(fn) |
DB.transaction(() async {...}, connection: 'legacy') |
async; Transactions are per connection, as in Laravel. |
DB::raw($sql) |
DB.raw(sql) |
|
DB::listen(fn (QueryExecuted $q) => ...) |
DB.listen((QueryExecuted q) {...}) |
|
DB::whenQueryingForLongerThan(500, fn) |
DB.whenQueryingForLongerThan(const Duration(milliseconds: 500), (q) {...}) |
Total per request, job or command; once. |
DB::enableQueryLog(); ... count(DB::getQueryLog()) |
final (result, count, queries) = await DB.withQueryCount(() => ...) |
|
->whereToday('starts_at') / wherePast / whereFuture / whereBeforeToday |
.whereToday('starts_at') / wherePast / whereFuture / whereBeforeToday |
Against Clock.now(). |
->insertGetId($values) |
await DB.table(t).insertGetId(values) |
async |
->upsert($values, $uniqueBy) |
.upsert(values, uniqueBy) |
async |
->chunkById(100, fn) |
.chunkById(100, (rows) async {...}) |
async |
->toSql() / toRawSql() |
.toSql() / .toRawSql() |
|
->selectSub($query, 'alias') |
.selectSub(query, 'alias') |
Schema & migrations#
| Laravel | Daravel | Notes |
|---|---|---|
->selectSub($query, 'alias') |
.selectSub(query, 'alias') |
|
Schema::create('users', fn (Blueprint $table) => ...) |
schema.create('users', (table) {...}) |
async |
Schema::table / dropIfExists / hasTable / hasColumn |
schema.table / dropIfExists / hasTable / hasColumn |
async |
$table->id() / string() / foreignId()->constrained() |
table.id() / string() / foreignId().constrained() |
|
$table->morphs('taggable') / nullableMorphs / uuidMorphs / ulidMorphs |
table.morphs('taggable') / nullableMorphs / uuidMorphs / ulidMorphs |
|
$table->timestamps() / softDeletes() / rememberToken() |
table.timestamps() / softDeletes() / rememberToken() |
|
return new class extends Migration { up / down } |
final class CreateX extends Migration { up / down } |
|
class DatabaseSeeder extends Seeder |
final class DatabaseSeeder extends Seeder |
|
User::factory()->count(3)->create() |
UserFactory().count(3).create() |
Auth & security#
| Laravel | Daravel | Notes |
|---|---|---|
auth()->user() / Auth::user() |
await Auth.user() |
async |
Auth::id() / check() / guest() |
await Auth.id() / check() / guest() |
async |
Auth::guard('api') |
Auth.guard('api') |
|
$user->createToken($name, $abilities) |
await Auth.issueToken(user, name, abilities: [...]) |
async |
$user->currentAccessToken()->delete() |
await Auth.tokenGuard().revokeCurrent() |
async |
$user->tokenCan('posts:read') |
await Auth.tokenGuard().tokenCan('posts:read') |
async |
Hash::make / check / needsRehash |
await Hash.make / check, Hash.needsRehash |
|
Crypt::encryptString / decryptString |
await Crypt.encryptString / decryptString |
async; Byte-compatible with APP_CIPHER=AES-256-CBC or AES-256-GCM. |
Crypt::encrypt / decrypt |
await Crypt.encrypt / decrypt |
async; Values are JSON-encoded, not PHP-serialized: not cross-runtime. |
php artisan env:encrypt / env:decrypt |
dartisan env:encrypt / env:decrypt |
Same .env.encrypted file (default and --readable); decrypted in memory at boot with DARAVEL_ENV_ENCRYPTION_KEY or LARAVEL_ENV_ENCRYPTION_KEY. |
Gate::define / allows / denies / authorize |
Gate.define / allows / denies / authorize |
async |
class PostPolicy |
final class PostPolicy extends Policy<Post> |
|
RateLimiter::for('login', fn) |
limiter.define('login', (request) => Limit.perMinute(5)) |
|
Limit::perMinute(60)->by($key) |
Limit.perMinute(60).by(key) |
Cache, queue, events, scheduling#
| Laravel | Daravel | Notes |
|---|---|---|
Cache::get / put / forget / has |
await Cache.get / put / forget / has |
async |
Cache::remember($key, $ttl, fn) |
await Cache.remember(key, ttl, () async {...}) |
async |
Cache::flexible($key, [$fresh, $stale], fn) |
await Cache.flexible(...) |
async |
Cache::lock('name', 10)->get(fn) |
await Cache.lock('name', ...) |
|
SendEmail::dispatch($user) |
await Queue.dispatch(SendEmail(user)) |
async |
Queue::later($delay, $job) |
await Queue.later(delay, job) |
async |
public function tags() (Horizon) |
List<String> get tags |
|
php artisan queue:pause database:default / queue:continue |
dartisan queue:pause database:default / queue:continue |
|
class SendEmail implements ShouldQueue |
final class SendEmail extends Job |
|
event(new OrderShipped($order)) |
await Event.dispatch(OrderShipped(order)) |
async |
Event::listen(OrderShipped::class, fn) |
Event.listen<OrderShipped>((e) async {...}) |
|
Event::fake() / Queue::fake() |
Event.fake() / Queue.fake() |
|
Schedule::command('reports:send')->daily() |
schedule.command('reports:send').daily() |
|
->everyFiveMinutes() / dailyAt('13:00') |
.everyFiveMinutes() / .dailyAt('13:00') |
|
->withoutOverlapping() / onOneServer() |
.withoutOverlapping() / .onOneServer() |
|
->timezone('America/New_York') |
.timezone('America/New_York') |
|
config('app.schedule_timezone') |
SCHEDULE_TIMEZONE |
No fallback to APP_TIMEZONE in 1.x (doctor warns). |
Resources, storage, logging, config#
| Laravel | Daravel | Notes |
|---|---|---|
class UserResource extends JsonResource { toArray } |
final class UserResource extends JsonResource<User> { toArray } |
|
UserResource::collection($users) |
JsonResource.collection(users, UserResource.new) |
|
->additional([...]) |
.additional({...}) |
|
$this->when($cond, $value) / whenLoaded('posts') |
when(cond, value) / whenLoaded('posts') |
|
public function with($request) |
Map<String, Object?> withMeta(Request? request) |
with is a reserved word in Dart. |
Storage::disk('public')->put($path, $contents) |
await Storage.disk('public').put(path, contents) |
|
Storage::url($path) |
Storage.url(path) |
|
Http::withToken($t)->retry(3, 100)->post($url, $data) |
await Http.withToken(t).retry(3).post(url, json: data) |
async |
Http::fake([...]) / Http::assertSent(...) |
Http.fake({...}).assertSent(...) |
|
php artisan down --secret / up |
dartisan down --secret / up |
Same storage/framework/down file; bypass cookie daravel_maintenance. |
Log::info($message, $context) |
Log.info(message, context) |
|
Log::withContext([...]) |
Log.withContext({...}) |
|
config('app.name') |
Config.get<String>('app.name') |
|
app(Service::class) |
App.make<Service>() |
|
resolve(Service::class) |
inject<Service>() / maybeInject<Service>() |
The type is inferred where the value goes: orders ?? inject(). |
$this->app->singleton(...) in AppServiceProvider::register() |
.withBindings((c) => c.singleton((c) => OrderService(c(), c()))) |
Calling the container (c()) resolves the type each argument needs. |
now() |
Clock.now() |
Frozen by freezeTime()/travel() in tests. |
Localization#
| Laravel | Daravel | Notes |
|---|---|---|
__('auth.failed') / trans('auth.failed', ['name' => $name]) |
trans('auth.failed', {'name': name}) |
No __: a leading underscore makes a Dart name private. |
trans_choice('apples', $count) |
transChoice('apples', count) |
Laravel's plural rules for 280 locales. |
Lang::get / Lang::has |
Lang.get / Lang.has |
|
App::setLocale('id') / App::getLocale() |
App.setLocale('id') / App.locale |
Per request, job or command scope. |
lang/id.json |
lang/id.json (loaded at boot) |
Feature flags (Pennant)#
| Laravel | Daravel | Notes |
|---|---|---|
Feature::define('beta', fn (User $user) => ...) |
Feature.define('beta', (scope) => ...) |
|
Feature::active('beta') / Feature::value('beta') |
await Feature.active('beta') / Feature.value('beta') |
async |
Feature::for($team)->active('beta') |
Feature.forScope(team).active('beta') |
for is a Dart keyword. |
Feature::activateForEveryone('beta') |
Feature.activateForEveryone('beta') |
|
EnsureFeaturesAreActive::using('beta') |
'feature:beta' |
Same features table, scope format and 400 response. |
Strings, helpers, collections#
| Laravel | Daravel | Notes |
|---|---|---|
Str::slug / snake / camel / studly / kebab / title / headline |
Str.slug / snake / camel / studly / kebab / title / headline |
|
Str::limit / words / mask / squish |
Str.limit / words / mask / squish |
|
Str::after / before / between / start / finish |
Str.after / before / between / start / finish |
|
Str::contains / startsWith / endsWith |
Str.contains / startsWith / endsWith |
|
Str::plural / singular / pluralStudly |
Str.plural / singular / pluralStudly |
English rules with common irregulars. |
Str::random / uuid / ulid / isUuid / isJson |
Str.random / uuid / ulid / isUuid / isJson |
|
Str::of($title)->after(':')->squish()->slug() |
title.after(':').squish().slug() |
Str methods chain on the string itself. |
now()->addMinutes(10) / Carbon::now()->subDays(7) |
10.minutes.fromNow / 7.days.ago |
|
$date->diffForHumans() / isPast() / isToday() / startOfDay() |
date.diffForHumans() / isPast / isToday / startOfDay |
Carbon's units and wording, en and id. |
$date->addMonth() / addMonthsNoOverflow(1) / startOfWeek() / between($a, $b) |
date.addMonths(1) / addMonthsNoOverflow(1) / startOfWeek / isBetween(a, b) |
Same overflow rules as Carbon. |
Context::add('tenant_id', $id) / Context::get('tenant_id') / addHidden |
Context.add('tenant_id', id) / Context.get<int>('tenant_id') / addHidden |
Logged, and carried into queued jobs. |
Number::format / percentage / currency / fileSize / abbreviate / ordinal |
Number.format / percentage / currency / fileSize / abbreviate / ordinal |
en and id locales, as Laravel's intl output. |
abort_if / abort_unless |
abortIf / abortUnless |
|
retry(, fn, ) |
await retry(times, (attempt) async {...}, delay:) |
async |
rescue(fn, ) |
await rescue(() async {...}, fallback:) |
async |
once(fn) |
once(key, () => ...) |
Keyed explicitly; memoized per request/job/command scope. |
blank / filled / tap / value / data_get |
blank / filled / tap / value / dataGet |
|
now() / today() |
now() / today() |
|
collect($rows)->pluck('email') |
rows.pluck('email') |
Extensions on Iterable: no conversion to a Collection. |
->keyBy / groupBy / chunk / unique |
.keyBy / groupBy / chunk / unique |
|
->sum / avg / sortBy / sortByDesc / partition |
.sumBy / avgBy / sortBy / sortByDesc / partition |
Testing#
| Laravel | Daravel | Notes |
|---|---|---|
$this->getJson('/api/users') |
await client.get('/api/users') |
async |
$this->postJson($uri, $data) |
await client.post(uri, json: data) |
async |
$this->actingAs($user) |
client.actingAs(user) |
|
->assertOk() / assertCreated() / assertUnprocessable() |
.assertOk() / .assertCreated() / .assertUnprocessable() |
|
->assertJsonPath('data.id', 1) |
.assertJsonPath('data.id', 1) |
|
use RefreshDatabase; |
refreshDatabase(() => app) |
|
$this->freezeTime() / travel(5)->minutes() |
freezeTime() / travel(Duration(minutes: 5)) |
Middleware aliases#
| Laravel | Daravel |
|---|---|
auth |
auth |
auth:sanctum |
auth:token |
throttle |
throttle |
can |
can |
signed |
signed |
cache.headers |
cache.headers |
Laravel\Pennant\Middleware\EnsureFeaturesAreActive |
feature |
Not available yet#
| Laravel | Plan |
|---|---|
| Sessions, CSRF, SPA cookie auth | Tier 2 |
| Mail, notifications, broadcasting | Tier 2/3 |
| Redis cache/queue/lock, S3 storage | Tier 2 |
| PHP serialize() for Crypt::encrypt payloads | Tier 2 (Laravel-compatible Crypt mode) |
| optional() helper | Not planned: Dart has ?. and ?? |
| app() / route() / url() global helpers | Use App.make |
| dd() / dump() | Not planned: use print or Log in development |