Converting a Laravel App
xr convert brings an existing Laravel application into a new Larust
project. It’s built around one hard rule, stated up front so the tool’s
behavior never surprises you: structure converts mechanically; business
logic never does.
- The core rule, and why
- Running it
- What actually converts
- Why templates are whole-file but validation rules are per-field
CONVERSION_REPORT.md- After conversion
- Next
The core rule, and why
“Trying to launch with a Rust equivalent of all of Laravel/Livewire/Horizon/Telescope/Filament would probably prevent the project from ever launching.”
That’s this project’s own stated risk assessment for automatic conversion, and it drives two firm scope decisions:
- Third-party (Composer) packages are never auto-ported. A small, hand-curated table maps a handful of known packages to their Larust equivalent; everything else is named, with its version constraint, in the generated report - never silently dropped, never guessed at.
- PHP business logic is never auto-translated - only mechanically regular structure is. A converter that looks like it converted a method body but got it subtly wrong is worse than an honest gap. Every converter in this tool either produces code it can verify is correct, or flags the input and leaves it for you - never a plausible-looking guess.
Running it
xr convert path/to/laravel-app --out path/to/new-larust-app
--out must not already exist (or must be empty) - there’s no
incremental/merge mode. Re-running a conversion on a project you’ve
already hand-edited needs a fresh output directory, not the same one.
xr convert --file resources/views/posts/show.blade.php --destination resources/views/posts/show.blade.xr
Re-converts a single template in isolation - useful for pulling one file through a converter fix, or a template you’ve since edited on the Laravel side, without redoing the whole project.
What actually converts
| Laravel | Converts to | Safety |
|---|---|---|
routes/web.php/api.php (Route::get/post/put/patch/delete, Route::resource) |
routes/*.rs |
Whole-route; Route::middleware(...)->group(...) is flagged, never silently dropped |
database/migrations/*.php (Schema::create/table, Blueprint) |
Real .sql migrations |
Whole-migration; timestamps() is always flagged - the schema converts, but the matching model still needs #[timestamps] added by hand for automatic created_at/updated_at population |
config/*.php |
config/*.rs |
Only fields matching Config’s fixed schema; anything else is named in the report |
.env’s APP_URL (a Laravel project’s .env rarely sets APP_PORT at all) |
app_port config resolution |
Lenient by design: APP_PORT wins if set, otherwise a port parsed out of APP_URL (http://127.0.0.1:8000 -> 8000) is used before falling back to this framework’s own generic default - see the FAQ |
Form Request rules() (pipe-string or array form) |
#[derive(FormRequest)] + #[validate(...)] |
Per-field - an unsupported rule (unique:*, e.g.) is dropped and flagged without affecting sibling fields |
resources/views/**/*.blade.php |
.blade.xr |
Whole-file - any unsupported directive or expression rejects the whole template, copied byte-for-byte into resources/views_needs_manual_conversion/ instead |
Models (fields, hasMany/belongsTo/etc.) |
#[derive(Model)] structs |
An unrecognized column type rejects the model; an inferred relationship is commented // inferred ... - verify |
| Controllers + Policies | Method stubs (todo!()) with the original PHP body preserved as a comment above |
Zero logic translation, by design |
| Events + Jobs | Struct definitions (constructor-property extraction) | A class-typed constructor property (e.g. public Post $post) is rejected, not guessed at |
| Composer packages | A named entry in the report | Never auto-ported |
Why templates are whole-file but validation rules are per-field
This is a deliberate, load-bearing difference, not an inconsistency.
#[derive(FormRequest)]’s rules reuse grammar Phase 1 already verified
and each #[validate(...)] attribute is independent Rust syntax - a bad
rule on one field can’t affect its siblings. A .blade.xr template’s
{{ }} expressions get spliced directly into view!’s generated code
(syn::parse_str::<syn::Expr>, zero PHP translation at that layer) - a
wrong translation there would break the converted app’s own compile,
not just add a report entry, so an unsupported construct anywhere in a
template takes the whole file down rather than risking a subtly wrong
partial conversion.
CONVERSION_REPORT.md
The trust mechanism the whole tool is built around, written alongside your converted app. Every single item the conversion touched lands in exactly one of three buckets - converted, flagged (with why), or rejected (with why) - nothing is silently dropped. Read it before you read anything else in the converted project; it’s the map of exactly what still needs your attention.
After conversion
The output is a real, ordinary Larust app - cargo build it, then follow
the same path as any hand-built one:
- Read
CONVERSION_REPORT.mdend to end. - Fill in the
todo!()controller/policy stubs, using the original PHP body preserved right above each one as your reference. - Hand-convert anything under
resources/views_needs_manual_conversion/into real.blade.xr(see Views & Templates for the supported directive/expression subset). - Verify every
// inferred ... - verifyrelationship comment on your converted models. xr migrateand confirm the schema looks right.
Next
You’ve now seen the whole framework. Architecture Overview if you want the crate-level “how it fits together” picture, or the FAQ for quick answers to common questions.