Coming from Laravel
This page is for developers who know Laravel. It shows the Laravel way and the Dox way side by side. The Dox code is real 3.x code. The Laravel code is Laravel 11.
Dox follows the ideas of Laravel: routers, controllers, middleware, an ORM with models and relations, migrations, a CLI. But Dox is Dart. It has static types, no reflection at run time, and it compiles to one native binary.
Routing
| Laravel | Dox |
|---|---|
Route::get('/ping', fn () => 'pong'); |
Route.get('/ping', (DoxRequest req) => 'pong'); |
Route::post('/blogs', [BlogController::class, 'store']); |
Route.post('/blogs', blogController.store); |
Route::get('/blogs/{id}', ...) and function show(int $id) |
Route.get('/blogs/{id}', ...) and show(int id) |
Route::get('/posts/{page?}', ...) |
Route.get('/posts/{page?}', (int? page) => ...) |
->where('path', '.*') |
Route.get('/docs/{page*}', (String page) => ...) |
Route::prefix('blogs')->group(function () { ... }); |
Route.group('/blogs', () { ... }); |
Route::middleware('auth')->group(...) |
Route.middleware(<dynamic>[AuthMiddleware()], () { ... }); |
Route::domain('admin.app.test')->group(...) |
Route.domain('admin.app.test', () { ... }); |
Route::resource('blogs', BlogController::class); |
Route.resource('blogs', BlogController()); |
routes/api.php with the api prefix |
lib/routes/api.dart, a Router class with prefix => 'api' |
In Dox, a controller is an object you make yourself. There is no BlogController::class string.
class ApiRouter extends Router {
@override
String get prefix => 'api';
@override
List<dynamic> get middleware => <dynamic>[];
@override
void register() {
BlogController blogController = BlogController();
Route.get('/blogs', blogController.index);
Route.resource('users', UserController());
}
}
Controllers
| Laravel | Dox |
|---|---|
php artisan make:controller BlogController |
dox create:controller Blog |
php artisan make:controller BlogController --resource |
dox create:controller Blog -r |
public function show(Request $request, int $id) |
Future<Blog?> show(DoxRequest req, int id) |
public function store(StoreBlogRequest $request) |
Future<Blog> store(CreateBlog body) |
| injected by the service container | filled by type: DoxRequest, one @Dto class, path params |
Dox fills a parameter by its type, not by its name. Path params go by position. A controller can be a class method, a static method or a plain function.
Middleware
| Laravel | Dox |
|---|---|
php artisan make:middleware Logger |
dox create:middleware Logger |
public function handle(Request $request, Closure $next) |
dynamic handle(IDoxRequest req) |
return $next($request); |
return req; |
return response('no', 401); |
return response('no').statusCode(401); |
abort(404); |
throw NotFoundHttpException(); |
$middleware->append(LogMiddleware::class) in bootstrap/app.php |
AppConfig(globalMiddleware: <dynamic>[LogMiddleware()]) |
class LoggerMiddleware implements IDoxMiddleware {
@override
dynamic handle(IDoxRequest req) {
print('${req.method} ${req.uri.path}');
return req; // go on. anything else is the response.
}
}
A plain function dynamic apiKey(DoxRequest req) also works as middleware.
Dox middleware runs before the controller only. There is no "after" step; use a responseHandler for that.
Requests and validation
| Laravel | Dox |
|---|---|
php artisan make:request StoreBlogRequest |
dox create:dto CreateBlog |
class StoreBlogRequest extends FormRequest with rules() |
@Dto() class CreateBlog with annotations on final fields |
'title' => ['required', 'string', 'max:50'] |
@MaxLength(50) final String title; |
'age' => ['nullable', 'integer', 'min:18'] |
@Min(18) final int? age; |
'email' => ['required', 'email', 'unique:users'] |
@Email() @Unique('users') final String email; |
'role' => Rule::in(['admin', 'user']) |
final Role role; (a Dart enum) |
'tags' => ['array', 'max:5'], 'tags.*' => 'string' |
@MaxItems(5) final List<String> tags; |
$request->validated() |
the DTO object itself, already typed |
$request->validate([...]) inside a method |
await req.validate<CreateBlog>() |
$request->input('name') |
req.input('name') |
$request->file('avatar') |
a RequestFile field in the DTO, or req.input('avatar') |
$request->user() |
req.auth?.user<User>() |
422 with {"errors": {...}} |
422 with a flat map {"email": "...", "items.1.quantity": "..."} |
@Dto()
class CreateBlog {
@Length(min: 3, max: 50)
final String title;
@Min(18)
final int? age; // nullable = optional
const CreateBlog({required this.title, this.age});
static const DtoSchema<CreateBlog> schema = $CreateBlogDto;
}
The rules are checked by generated Dart code, not by rule strings at run time.
Run dox build_runner:build after you change a DTO. See Validation.
Responses
| Laravel | Dox |
|---|---|
return 'pong'; |
return 'pong'; |
return ['a' => 1]; |
return <String, int>{'a': 1}; |
return $blog; (JSON) |
return blog; (JSON from toJson()) |
return response($data, 201)->header('X-A', 'b'); |
return response(data).statusCode(201).header('X-A', 'b'); |
return response()->json($data); |
return data; (a Map or List is JSON) |
return response()->file($path); |
return File(path); |
return response()->download($path); |
return Storage().download(path); |
->cookie('theme', 'dark') |
.cookie(DoxCookie('theme', 'dark')) |
abort(404, 'Not found'); |
throw NotFoundHttpException(message: 'Not found'); |
new BlogResource($blog) |
BlogSerializer(blog) |
Eloquent vs Dox Model
| Laravel | Dox |
|---|---|
php artisan make:model Blog -m |
dox create:model Blog -m |
class Blog extends Model |
@DoxModel() class Blog extends BlogGenerator |
protected $table = 'blogs'; |
@DoxModel(table: 'blogs') |
protected $primaryKey = 'uid'; |
@DoxModel(primaryKey: 'uid') |
use SoftDeletes; |
@DoxModel(softDelete: true) |
public $timestamps = false; |
@DoxModel(timestamps: false) |
protected $hidden = ['password']; |
List<String> get hidden => <String>['password']; |
protected $fillable = [...] |
not needed. Only @Column() fields are saved. |
protected $casts = ['meta' => 'array']; |
the field type: Map<String, dynamic>? meta; |
mutator setSlugAttribute() |
@Column(beforeSave: makeSlug) |
accessor getStatusAttribute() |
@Column(beforeGet: readStatus) |
$appends with an accessor |
@Computed() String get fullName => ...; |
Blog::find(1) |
await Blog().find(1) |
Blog::where('age', '>', 18)->get() |
await Blog().where('age', '>', 18).get() |
Blog::create([...]) |
await Blog().create(<String, dynamic>{...}) |
$blog->save() |
await blog.save() |
$blog->delete() |
await blog.delete() |
Blog::withTrashed() |
Blog().withTrash() |
$blog->restore() |
await blog.restore() |
Blog::with('posts')->get() |
await Blog().preload('posts').get() |
$blog->load('posts') |
await blog.$getRelation('posts') |
$user->posts()->where(...)->get() |
user.related<Post>('posts')?.where(...).get() |
$with = ['posts'] |
@HasMany(Post, eager: true) |
hasOne, hasMany, belongsTo, belongsToMany |
@HasOne, @HasMany, @BelongsTo, @ManyToMany |
local scope scopeActive() |
onQuery: onlyActive on a relation, or a static method that returns a query |
creating, saved, deleting events |
onCreating(), onSaved(), onDeleting() hooks in the model |
Blog::observe(BlogObserver::class) |
Model.observe<Blog>(BlogObserver()) |
$blog->saveQuietly() |
await blog.saveQuietly() |
Model::withoutEvents(fn) |
Model.withoutEvents(() async { ... }) |
@DoxModel(table: 'blogs', softDelete: true)
class Blog extends BlogGenerator {
@Column()
String? title;
@Column(name: 'user_id')
int? userId;
@BelongsTo(User)
User? user;
@override
Future<void> onCreating() async {
title = title?.trim();
}
}
Every column field is nullable, because a new model has no values yet.
Dox has no global scopes and no $casts list. Use the field type, beforeSave and beforeGet.
Run dox build_runner:build after you change a model. See Model.
Query builder
| Laravel | Dox |
|---|---|
DB::table('users')->get() |
await QueryBuilder.table('users').get() |
->where('a', 1)->orWhere('b', 2) |
.where('a', 1).orWhere('b', 2) |
->whereIn('id', [1, 2]) |
.whereIn('id', <int>[1, 2]) |
->whereNull('deleted_at') |
.whereNull('deleted_at') |
->whereRaw('lower(name) = ?', [$n]) |
.whereRaw('lower(name) = @n', <String, dynamic>{'n': n}) |
->select('id', 'name') |
.select(<String>['id', 'name']) |
->orderBy('name', 'desc')->limit(10)->offset(20) |
.orderBy('name', 'desc').limit(10).offset(20) |
->join('posts', 'posts.user_id', '=', 'users.id') |
.join('posts', 'posts.user_id', 'users.id') |
->groupBy('age') |
.groupBy('age') on QueryBuilder.table() (returns maps) |
->count() |
await ....count() |
->paginate(10) |
.paginate(currentPage: 1, perPage: 10) |
->insert([...]) |
.insertMultiple(<Map<String, dynamic>>[...]) |
->update([...]) |
.update(<String, dynamic>{...}) |
->delete() |
.delete() |
DB::select('select ...', [$id]) |
QueryBuilder.query('select ... @id', substitutionValues: {'id': id}) |
DB::transaction(fn) |
QueryBuilder.transaction(() async { ... }) |
->toSql() |
.toSql() |
DB::connection('mysql') |
.withConnection('mysql') |
Values are always bound parameters. Names you pass are quoted. Do not pass user input as a column name or an operator.
Migrations and schema
| Laravel | Dox |
|---|---|
php artisan make:migration create_users_table |
dox make:migration create_users_table |
database/migrations/ |
db/migration/ |
Schema::create('users', function (Blueprint $table) {...}) |
await schema.create('users', (Table table) {...}) |
$table->id(); |
table.id(); |
$table->string('email')->unique(); |
table.string('email').unique(); |
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); |
table.foreignId('user_id').constrained().cascadeOnDelete(); |
$table->timestamps(); |
table.timestamps(); |
$table->softDeletes(); |
table.softDeletes(); |
$table->string('name', 100)->nullable()->change(); |
table.string('name', 100).nullable().change(); |
Schema::dropIfExists('users'); |
await schema.dropIfExists('users'); |
php artisan migrate |
dox migrate (or dox migration:run) |
php artisan migrate:rollback --step=2 |
dox migration:rollback --step=2 |
php artisan migrate:reset, migrate:refresh, migrate:status |
dox migration:reset, migration:refresh, migration:status |
class CreateUsersTable extends Migration {
@override
Future<void> up(Schema schema) async {
await schema.create('users', (Table table) {
table.id();
table.string('email').unique();
table.timestamps();
});
}
@override
Future<void> down(Schema schema) async {
await schema.dropIfExists('users');
}
}
Dart can not load files at run time in an AOT binary. So the CLI keeps a list of your migrations in db/migration/migrations.dart. You do not edit it.
The server can run the migrations at start. See Migration.
Artisan vs the dox CLI
| Laravel | Dox |
|---|---|
laravel new blog |
dox create blog |
php artisan serve |
dox serve (or dox s), with hot restart and build_runner watch |
php artisan make:controller |
dox create:controller |
php artisan make:model Blog -m |
dox create:model Blog -m |
php artisan make:request |
dox create:dto |
php artisan make:middleware |
dox create:middleware |
php artisan make:resource |
dox create:serializer |
php artisan make:migration |
dox make:migration |
php artisan migrate |
dox migrate |
php artisan key:generate |
dox key:generate |
php artisan route:list |
not there. Use the OpenAPI docs page at /docs. |
php artisan tinker |
not there |
php artisan optimize |
dox build (one native binary in build/bundle) |
Two commands have no Laravel twin: dox build_runner:build makes the generated code, and dox env:generate makes the typed env class.
See Dox CLI.
Auth
| Laravel | Dox |
|---|---|
config/auth.php guards and providers |
Auth.initialize(AuthConfig(guards: ...)) in a service |
Auth::attempt(['email' => $e, 'password' => $p]) |
await Auth().attempt(<String, dynamic>{'email': e, 'password': p}) gives a JWT, or null |
Auth::login($user) |
Auth().login(user) gives a JWT |
Auth::user() |
req.auth?.user<User>() |
->middleware('auth:sanctum') |
AuthMiddleware() from dox_auth |
Hash::make($p), Hash::check($p, $hash) |
Hash.make(p), Hash.verify(p, hash) |
Dox auth is a JWT in the Authorization header. There are no sessions, no cookies and no Sanctum tokens table.
See Authentication.
Cache
| Laravel | Dox |
|---|---|
config/cache.php |
lib/config/cache.dart, CacheConfig(defaultStore:, stores:) |
Cache::put('k', $v, 600) |
await Cache().put('k', v, duration: Duration(minutes: 10)) |
Cache::get('k', 'default') |
await Cache().get('k', 'default') |
Cache::remember('k', 600, fn) |
await Cache().remember('k', Duration(minutes: 10), fn) |
Cache::forget('k') |
await Cache().forget('k') |
Cache::store('redis') |
Cache().store('redis') |
Cache::tags(['a']) |
Cache().tags(<String>['a']) |
Cache::lock('k', 10) |
Cache().lock('k', Duration(seconds: 10)) |
| drivers: file, redis, memcached, database, array | drivers: file, redis, memory, null |
A value must be a JSON type. The memory store is per isolate. See Cache.
Storage
| Laravel | Dox |
|---|---|
config/filesystems.php disks |
lib/config/storage.dart, StorageConfig(defaultDisk:, disks:) |
Storage::put('a.txt', $bytes) |
await Storage().put('files', bytes, extension: 'txt') (a uuid file name) |
$request->file('f')->store('avatars') |
await file.store('avatars') or Storage().putRequestFile('avatars', file) |
Storage::get($path) |
await Storage().get(path) |
Storage::exists($path) |
await Storage().exists(path) |
Storage::delete($path) |
await Storage().delete(path) |
Storage::url($path) |
await Storage().url(path) |
Storage::temporaryUrl($path, now()->addMinutes(10)) |
await Storage().signedUrl(path, expiresIn: Duration(minutes: 10)) |
Storage::disk('s3') |
Storage().disk('s3') |
Storage::download($path) |
return Storage().download(path); |
| drivers: local, s3, ftp, sftp | drivers: local, s3 (also R2, MinIO, Spaces), memory |
See File Storage.
Config and .env
| Laravel | Dox |
|---|---|
config/*.php files |
lib/config/*.dart files, put together in AppConfig |
env('APP_KEY') |
env.appKey (a getter in lib/config/env.dart) |
env('APP_PORT', 3000) |
env.appPort (the default is in the getter: int get appPort => 3000;) |
config('app.name') |
env.appName from the typed env class (@DoxEnv()) |
php artisan config:cache |
not needed. The typed env is read once, at start. |
The typed env class gives you an int for APP_PORT and a build error for a bad type.
A missing key stops the app at start, with a list of every missing key. See Environment Variables.
Testing
| Laravel | Dox |
|---|---|
php artisan test |
dart test |
$this->get('/api/ping')->assertStatus(200) |
start the server once, then http.get(Uri.parse('$baseUrl/api/ping')) |
RefreshDatabase |
run the migrations in setUpAll, or use SQLite :memory: |
Storage::fake('s3') |
Dox().storage.fake('s3') |
Cache::fake() |
Dox().cache = CacheManager(CacheConfig(...)) with a memory store |
$this->actingAs($user) |
send the JWT from Auth().login(user) in the Authorization header |
Dox has no fake HTTP kernel. A test starts the real server with Dox().totalIsolate(1) and sends real requests.
See Testing.
What is different
- No service container in the controller. Laravel makes your controllers and injects what the constructor asks for.
In Dox you make the controller yourself (
BlogController()) and pass what it needs. Dox fills only the method parameters: the request, one DTO and the path params.Dox().iocis a small registry (register,registerSingleton,get), but nothing is injected for you. - Isolates, not PHP-FPM workers. One process runs one isolate per CPU core. Each isolate runs your whole app and has its own memory. A static value or a singleton lives once per isolate. A PHP worker starts from zero on each request. A Dox isolate keeps its state between requests, so a memory leak stays. Anything that must be shared goes to the database, Redis or files. See How Dox works.
- Code generation, not reflection. Laravel reads your classes at run time. Dart has no usable reflection in a compiled app.
So
build_runnerwithdox_builderwrites the model code, the DTO checks, the env class and the OpenAPI docs into.g.dartfiles. Rundox build_runner:buildafter a change, or letdox servewatch for you. - One native binary.
dox buildmakes an AOT binary inbuild/bundle. There is no interpreter, novendor/folder and nocomposer installon the server. The Docker image is about 11 MB. - A native HTTP server and native database drivers. Rust parses HTTP and talks to Postgres and MySQL. Your code stays in Dart. The HTTP server comes ready-built. The database drivers need Rust (
cargo) to build; without it, Dox uses the Dart drivers. - Types everywhere. A DTO is a class with typed fields. A path param is an
int. A model field is aString?. The compiler finds many mistakes before you run the app. - Async is explicit. Database calls return a
Future. You writeawait. One isolate serves many requests at the same time while it waits for the database. - JSON API first. Dox returns text, JSON, files and streams. It has no view layer.
What is missing today
Dox 3.0 does not have these Laravel features. Check the packages list for what is there.
| Laravel | Dox 3.0 |
|---|---|
Queues and jobs (dispatch(new Job)) |
not there. Use a database table, Redis or a separate worker process. |
Task scheduler (schedule()->daily()) |
not there. Use cron or systemd timers to call a route or a Dart script. |
Events and listeners (Event::dispatch, $listen) |
not there. Model events (hooks and observers) are there. |
Blade views, view() |
not there. Dox is for APIs. Serve a front end from a CDN or a proxy. |
Mail (Mail::to()) |
not there. Use a Dart mail package or an HTTP mail API. |
| Notifications | not there |
| Sessions and session cookies | not there. Auth uses a JWT. Encrypted cookies are there. |
| Policies and gates | not there. Check the user in a middleware or in the controller. |
Global scopes, $casts, $fillable |
not there. See Eloquent vs Dox Model. |
| Database seeders and factories | not there. Write a Dart script, or a migration with data. |
| Rate limiting middleware | not there. Write a middleware with the cache, or use a proxy. |
php artisan route:list, tinker |
not there |
| Savepoints in nested transactions | not there. A nested transaction() joins the outer one. |
| WebSocket on the native server | not there yet. An app with WebSocket routes uses the dart:io server. |
What is there, as separate packages: validation, OpenAPI docs, storage, cache, Socket.IO, WebSocket and auth.