DEV Community

A0mineTV
A0mineTV

Posted on

Laravel 13 + Inertia + Vue 3 + TypeScript: A Practical Upgrade Path from Laravel 12

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}%");
    }
}
Enter fullscreen mode Exit fullscreen mode

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-stability and prefer-stable must be top-level keys, not inside require
  • you may need prefer-stable: true to 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
}
Enter fullscreen mode Exit fullscreen mode

If Composer gets stuck on conflicts, I recommend a clean resolution pass:

rm -rf vendor composer.lock
composer install
php artisan --version
Enter fullscreen mode Exit fullscreen mode

2) Inertia server setup

Install the Laravel adapter:

composer require inertiajs/inertia-laravel -W
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'),
            ],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Laravel 13 uses bootstrap/app.php middleware registration (no Http\Kernel.php), so make sure your HandleInertiaRequests is 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
Enter fullscreen mode Exit fullscreen mode

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)
  },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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}%");
    }
}
Enter fullscreen mode Exit fullscreen mode

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.');
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

And then:

const form = useForm<ProductForm>({
  name: '',
  price_cents: 0,
  is_active: true,
})
Enter fullscreen mode Exit fullscreen mode

Feature 2: Reviews (Eloquent relations + Inertia pages)

Once CRUD was working, I added reviews to exercise Eloquent relationships.

Database shape

  • products 1—N reviews
  • customers 1—N reviews
  • 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();
Enter fullscreen mode Exit fullscreen mode

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_type
  • mediable_id

Migration helper:

$table->morphs('mediable');
Enter fullscreen mode Exit fullscreen mode

Then:

  • Media has morphTo()
  • Product has morphMany(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
Enter fullscreen mode Exit fullscreen mode
pnpm dev
Enter fullscreen mode Exit fullscreen mode

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)