Laravel upgrades are often discussed as dry changelogs and release notes. To understand the real-world impact, I built a functional application using Laravel 13 + Inertia + Vue 3 + TypeScript and compared the experience with Laravel 12.
The experiment focused on shipping actual features: CRUD screens, validation, complex Eloquent queries, and relationships like reviews and polymorphic media.
This article is a hands-on summary of what I built and what I learned.
Note on Timing: At the time of this build, Laravel 13 was available as
13.x-dev. Expect minor ecosystem hiccups when using development branches.
1. What Really Changes from Laravel 12 → Laravel 13
The PHP Baseline Move
The most significant "real-world" difference is the baseline: Laravel 13 pushes the minimum PHP version forward. While beneficial long-term, it immediately impacts your infrastructure, requiring updates to CI images, Docker base images, and hosting environments like Forge or Vapor. In practice, the upgrade cost often lies more in your environment and dependencies than in your application code.
Attributes Become First-Class
Laravel 13 leans heavily into PHP Attributes (#[...]) for configuration that previously lived in protected class properties. This makes classes more declarative; you can open a model and immediately see its exports, permissions, and scopes at the top of the file.
Inertia Stability
One major advantage is that Inertia stays stable during this transition. You can modernize your backend baseline while keeping your SPA-like frontend workflow—using Inertia::render(), Vue components, and useForm<T>()—exactly the same.
2) Attributes (annotations) become first-class
Laravel 13 leans into PHP Attributes (#[...]) for configuration that used to live in class properties.
This is not about “saving lines of code”. It’s about making classes more declarative: you open a model and immediately see what it allows/exports and which scopes exist.
Here’s the exact style I used in the project (more on it later):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Attributes\Scope;
#[Fillable(['name', 'price_cents', 'is_active'])]
#[Visible(['id', 'name', 'price_cents', 'is_active'])]
class Product extends Model
{
#[Scope]
protected function search(Builder $query, string $q): void
{
$query->where('name', 'like', "%{$q}%");
}
}
3) Inertia stays stable (and that’s a good thing)
On the frontend side, Inertia doesn’t “change with Laravel 13”. The big value is that you can modernize the backend while keeping your SPA-like workflow the same:
- controller returns
Inertia::render(...) - pages are Vue components
- forms are powered by
useForm<T>()
So your upgrade effort is concentrated in backend baseline + patterns, not in “rewriting your frontend”.
Project setup: Laravel 13 + Inertia + Vue 3 + TypeScript
1) Composer + environment
If you are on 13.x-dev, keep your composer.json clean:
-
minimum-stabilityandprefer-stablemust be top-level keys, not insiderequire - you may need
prefer-stable: trueto avoid pulling dev versions for everything - some packages may lag behind (I hit this with
laravel/tinker)
A minimal starting point:
{
"require": {
"php": "^8.3",
"laravel/framework": "13.x-dev"
},
"minimum-stability": "dev",
"prefer-stable": true
}
If Composer gets stuck on conflicts, I recommend a clean resolution pass:
rm -rf vendor composer.lock
composer install
php artisan --version
2) Inertia server setup
Install the Laravel adapter:
composer require inertiajs/inertia-laravel -W
Create the root template at resources/views/app.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@vite(['resources/css/app.css', 'resources/js/app.ts'])
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
Then create a middleware to share props (auth user, flash messages, etc.):
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user(),
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
];
}
}
Laravel 13 uses
bootstrap/app.phpmiddleware registration (noHttp\Kernel.php), so make sure yourHandleInertiaRequestsis appended to the web middleware group.
3) Vue 3 + TypeScript + Vite
Install dependencies (example with pnpm):
pnpm add vue @inertiajs/vue3
pnpm add -D typescript vue-tsc @types/node @vitejs/plugin-vue
resources/js/app.ts:
import './bootstrap'
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'
createInertiaApp({
resolve: (name) =>
resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})
Feature 1: Products CRUD (Inertia + TS)
Generate the backend pieces
php artisan make:model Product -m
php artisan make:controller ProductController --resource
php artisan make:request StoreProductRequest
php artisan make:request UpdateProductRequest
Migration (simplified):
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->unsignedInteger('price_cents');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Laravel 13-style model configuration with attributes
Here’s the model again (this was the main “Laravel 13” part of the CRUD):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Attributes\Scope;
#[Fillable(['name', 'price_cents', 'is_active'])]
#[Visible(['id', 'name', 'price_cents', 'is_active'])]
class Product extends Model
{
#[Scope]
protected function search(Builder $query, string $q): void
{
$query->where('name', 'like', "%{$q}%");
}
}
Controller (pagination + search)
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ProductController extends Controller
{
public function index(Request $request)
{
$q = $request->string('q')->toString();
$products = Product::query()
->when($q !== '', fn ($query) => $query->search($q)) // attribute scope
->latest()
->paginate(10)
->withQueryString();
return Inertia::render('Products/Index', [
'products' => $products,
'filters' => ['q' => $q],
]);
}
public function create()
{
return Inertia::render('Products/Create');
}
public function store(StoreProductRequest $request)
{
Product::create($request->validated());
return redirect()
->route('products.index')
->with('success', 'Product created.');
}
public function edit(Product $product)
{
return Inertia::render('Products/Edit', [
'product' => $product->only('id', 'name', 'price_cents', 'is_active'),
]);
}
public function update(UpdateProductRequest $request, Product $product)
{
$product->update($request->validated());
return redirect()
->route('products.index')
->with('success', 'Product updated.');
}
public function destroy(Product $product)
{
$product->delete();
return redirect()
->route('products.index')
->with('success', 'Product deleted.');
}
}
TypeScript-friendly Inertia pages
On the Vue side, the most important thing is typing your form state and avoiding v-model on readonly props.
Example form type:
type ProductForm = {
name: string
price_cents: number
is_active: boolean
}
And then:
const form = useForm<ProductForm>({
name: '',
price_cents: 0,
is_active: true,
})
Feature 2: Reviews (Eloquent relations + Inertia pages)
Once CRUD was working, I added reviews to exercise Eloquent relationships.
Database shape
-
products1—Nreviews -
customers1—Nreviews - unique constraint:
(product_id, customer_id)to ensure one review per customer per product
That gives you:
- relationship loading (
with('customer')) - aggregates (
withCount,withAvg) - nested routes (
products/{product}/reviews)
Useful Eloquent aggregate
Product::query()
->whereKey($product->id)
->withCount('reviews')
->withAvg('reviews', 'rating')
->first();
This kind of query is where Laravel’s ORM really shines: you get meaningful stats without hand-writing raw SQL everywhere.
Feature 3: Polymorphic media (one table, multiple parents)
A polymorphic relation lets you attach media to multiple models (Product, Customer, Order...) using one media table:
mediable_typemediable_id
Migration helper:
$table->morphs('mediable');
Then:
-
MediahasmorphTo() -
ProducthasmorphMany(Media::class, 'mediable')
Why it’s great:
- you don’t need
product_images,customer_avatars,order_files... - you get a uniform API in Eloquent (
$model->media()->create(...))
Running the app (backend + frontend)
Two terminals:
php artisan serve
pnpm dev
Or a single command if you wired a composer run dev script that starts both processes.
Lessons learned (the honest part)
-
Laravel 13 dev branch is usable, but expect dependency mismatches. If a package doesn’t support
^13, either drop it temporarily or use its dev branch. - The biggest win of Laravel 13 in my build was declarative configuration with attributes. It doesn’t rewrite your app, but it makes your codebase easier to read as it grows.
- Inertia is a perfect match for this kind of exploration: you can build real screens fast while keeping backend logic “Laravel-native”.
Next steps (if you want to push the ORM further)
If you want to turn this into an even better Eloquent playground:
- product variants (Product → ProductVariant, options/values, complex pivots)
- inventory & stock movements (transactions + aggregates)
- multi-tenant scoping (global scopes + tenant_id)
Top comments (0)