· Admin

Laravel + Vue 3 Composition API ile Modern SPA Gelistirme

Son yillarda full-stack gelistirmenin en buyuk sorunu su oldu: Backend gelisitricisi ayri API yaziyor, frontend gelisiticisi ayri SPA yapiyor, ikisi arasindaki senkronizasyon surekli sorun cikariyor. REST API route'lari, token yonetimi, CORS, state management... Inertia.js bu sorunu kokunden cozuyor. Bu yazida Laravel + Vue 3 Composition API + Inertia.js ile nasil modern bir SPA gelistirilecegini anlatacagim.

Neden Inertia.js?

Inertia'yi anlamak icin once klasik SPA mimarisindeki sorunlara bakalim:

Klasik SPA (Laravel API + Vue SPA):

Frontend (Vue SPA)          Backend (Laravel API)
    |                           |
    |-- GET /api/users -------> |
    |<-- JSON response -------- |
    |                           |
    |-- POST /api/users ------> |
    |<-- JSON response -------- |

Bu yaklasimda:

  • REST API endpoint'leri yazmaniz lazim
  • Authentication token yonetimi (JWT, Sanctum)
  • CORS konfigurasyonu
  • Frontend'de state management (Pinia/Vuex)
  • API versiyonlama
  • Error handling iki tarafta ayri ayri

Inertia Yaklasimi:

Frontend (Vue + Inertia)    Backend (Laravel)
    |                           |
    |-- GET /users -----------> | Controller return Inertia::render()
    |<-- Vue component + props  |
    |                           |
    |-- POST /users ----------> | Normal form submit
    |<-- Redirect + flash ------ |

Inertia ile:

  • REST API yazmaniza gerek yok
  • Laravel controller'lari direkt Vue component'lerine props gonderir
  • Session-based authentication (Laravel'in kendi sistemi)
  • Form submission normal Laravel formu gibi calisiyor
  • Flash message, validation error hepsi otomatik
  • SEO icin SSR desteği var

Kurulum

Laravel + Inertia + Vue 3 Starter

# Yeni proje
laravel new myapp
cd myapp

# Inertia server-side
composer require inertiajs/inertia-laravel

# Inertia middleware
php artisan inertia:middleware

Middleware'i bootstrap/app.php'ye ekleyin:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\HandleInertiaRequests::class,
    ]);
})

Frontend tarafini kuralim:

# Vue 3 + Inertia client
npm install @inertiajs/vue3 vue

# Vite plugin
npm install @vitejs/plugin-vue

Vite Konfigurasyonu

// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({
            input: 'resources/js/app.js',
            refresh: true,
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
    ],
});

Inertia App Kurulumu

// resources/js/app.js
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';

createInertiaApp({
    title: (title) => `${title} - ProjeMan`,
    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);
    },
    progress: {
        color: '#3b82f6',
        showSpinner: true,
    },
});

Root Template

{{-- resources/views/app.blade.php --}}
<!DOCTYPE html>
<html lang="tr">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ config('app.name') }}</title>
    @vite(['resources/js/app.js', 'resources/css/app.css'])
    @inertiaHead
</head>
<body class="antialiased">
    @inertia
</body>
</html>

Dizin Yapisi

resources/js/
    app.js                    # Inertia bootstrap
    Pages/                    # Sayfa component'leri
        Dashboard.vue
        Users/
            Index.vue
            Create.vue
            Edit.vue
            Show.vue
        Projects/
            Index.vue
            Create.vue
    Components/               # Yeniden kullanilabilir component'ler
        UI/
            Button.vue
            Input.vue
            Modal.vue
        Layout/
            AppLayout.vue
            GuestLayout.vue
            Sidebar.vue
            Navbar.vue
    Composables/              # Vue 3 composable'lar
        useFlash.js
        useConfirm.js
        useDebounce.js
        usePermission.js

Controller'dan Vue'ya Veri Gonderme

Inertia'nin gucu burada. Normal bir Laravel controller yaziyorsunuz ama view yerine Inertia component render ediyorsunuz:

// app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class UserController extends Controller
{
    public function index(Request $request): Response
    {
        return Inertia::render('Users/Index', [
            'users' => User::query()
                ->when($request->search, function ($query, $search) {
                    $query->where('name', 'like', "%{$search}%")
                        ->orWhere('email', 'like', "%{$search}%");
                })
                ->orderBy('created_at', 'desc')
                ->paginate(15)
                ->withQueryString()
                ->through(fn ($user) => [
                    'id' => $user->id,
                    'name' => $user->name,
                    'email' => $user->email,
                    'created_at' => $user->created_at->format('d.m.Y'),
                    'can' => [
                        'edit' => $request->user()->can('update', $user),
                        'delete' => $request->user()->can('delete', $user),
                    ],
                ]),
            'filters' => $request->only(['search']),
        ]);
    }

    public function create(): Response
    {
        return Inertia::render('Users/Create', [
            'roles' => Role::all()->map->only('id', 'name'),
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => ['required', 'confirmed', Password::defaults()],
            'role_id' => 'required|exists:roles,id',
        ]);

        User::create($validated);

        return redirect()->route('users.index')
            ->with('success', 'Kullanici basariyla olusturuldu.');
    }

    public function edit(User $user): Response
    {
        return Inertia::render('Users/Edit', [
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
                'role_id' => $user->role_id,
            ],
            'roles' => Role::all()->map->only('id', 'name'),
        ]);
    }

    public function update(Request $request, User $user)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email,' . $user->id,
            'role_id' => 'required|exists:roles,id',
        ]);

        $user->update($validated);

        return redirect()->route('users.index')
            ->with('success', 'Kullanici guncellendi.');
    }

    public function destroy(User $user)
    {
        $user->delete();

        return redirect()->route('users.index')
            ->with('success', 'Kullanici silindi.');
    }
}

Dikkat edin: Bu tamamen normal bir Laravel controller. Tek fark return view() yerine return Inertia::render() kullanmamiz. Route'lar da tamamen normal:

// routes/web.php
Route::middleware('auth')->group(function () {
    Route::resource('users', UserController::class);
});

Vue 3 Composition API ile Sayfa Component'leri

Users/Index.vue

<script setup>
import { ref, watch } from 'vue';
import { router, Link, Head } from '@inertiajs/vue3';
import AppLayout from '@/Components/Layout/AppLayout.vue';
import Button from '@/Components/UI/Button.vue';
import { useFlash } from '@/Composables/useFlash';
import { useDebounce } from '@/Composables/useDebounce';

const props = defineProps({
    users: Object,
    filters: Object,
});

const { flash } = useFlash();

// Arama
const search = ref(props.filters.search || '');
const debouncedSearch = useDebounce(search, 300);

watch(debouncedSearch, (value) => {
    router.get('/users', { search: value }, {
        preserveState: true,
        replace: true,
    });
});

// Silme
function deleteUser(user) {
    if (confirm(`${user.name} kullanicisini silmek istediginize emin misiniz?`)) {
        router.delete(`/users/${user.id}`);
    }
}
</script>

<template>
    <Head title="Kullanicilar" />

    <AppLayout>
        <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
            <!-- Baslik ve Aksiyonlar -->
            <div class="flex items-center justify-between mb-6">
                <h1 class="text-2xl font-bold text-gray-900">Kullanicilar</h1>
                <Link href="/users/create">
                    <Button variant="primary">Yeni Kullanici</Button>
                </Link>
            </div>

            <!-- Flash Mesaj -->
            <div v-if="flash.success" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-green-700">
                {{ flash.success }}
            </div>

            <!-- Arama -->
            <div class="mb-4">
                <input
                    v-model="search"
                    type="text"
                    placeholder="Kullanici ara..."
                    class="w-full sm:w-80 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
                />
            </div>

            <!-- Tablo -->
            <div class="bg-white shadow rounded-lg overflow-hidden">
                <table class="min-w-full divide-y divide-gray-200">
                    <thead class="bg-gray-50">
                        <tr>
                            <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Ad</th>
                            <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">E-posta</th>
                            <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tarih</th>
                            <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Islemler</th>
                        </tr>
                    </thead>
                    <tbody class="divide-y divide-gray-200">
                        <tr v-for="user in users.data" :key="user.id" class="hover:bg-gray-50">
                            <td class="px-6 py-4 text-sm font-medium text-gray-900">
                                {{ user.name }}
                            </td>
                            <td class="px-6 py-4 text-sm text-gray-500">
                                {{ user.email }}
                            </td>
                            <td class="px-6 py-4 text-sm text-gray-500">
                                {{ user.created_at }}
                            </td>
                            <td class="px-6 py-4 text-right text-sm space-x-2">
                                <Link
                                    v-if="user.can.edit"
                                    :href="`/users/${user.id}/edit`"
                                    class="text-primary-600 hover:text-primary-800"
                                >
                                    Duzenle
                                </Link>
                                <button
                                    v-if="user.can.delete"
                                    @click="deleteUser(user)"
                                    class="text-red-600 hover:text-red-800"
                                >
                                    Sil
                                </button>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </div>

            <!-- Sayfalama -->
            <div class="mt-4 flex justify-center">
                <template v-for="link in users.links" :key="link.label">
                    <Link
                        v-if="link.url"
                        :href="link.url"
                        v-html="link.label"
                        class="px-3 py-1 mx-1 rounded text-sm"
                        :class="link.active ? 'bg-primary-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'"
                    />
                    <span
                        v-else
                        v-html="link.label"
                        class="px-3 py-1 mx-1 text-sm text-gray-400"
                    />
                </template>
            </div>
        </div>
    </AppLayout>
</template>

useForm ile Form Yonetimi

Inertia'nin useForm composable'i form islemlerini inanilmaz kolaylastiriyor:

<script setup>
import { useForm, Head, Link } from '@inertiajs/vue3';
import AppLayout from '@/Components/Layout/AppLayout.vue';
import Button from '@/Components/UI/Button.vue';

const props = defineProps({
    roles: Array,
});

const form = useForm({
    name: '',
    email: '',
    password: '',
    password_confirmation: '',
    role_id: '',
});

function submit() {
    form.post('/users', {
        onSuccess: () => form.reset(),
    });
}
</script>

<template>
    <Head title="Yeni Kullanici" />

    <AppLayout>
        <div class="max-w-2xl mx-auto py-6 px-4">
            <h1 class="text-2xl font-bold text-gray-900 mb-6">Yeni Kullanici</h1>

            <form @submit.prevent="submit" class="space-y-6">
                <!-- Ad -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">Ad Soyad</label>
                    <input
                        v-model="form.name"
                        type="text"
                        class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
                        :class="{ 'border-red-500': form.errors.name }"
                    />
                    <p v-if="form.errors.name" class="mt-1 text-sm text-red-600">
                        {{ form.errors.name }}
                    </p>
                </div>

                <!-- E-posta -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">E-posta</label>
                    <input
                        v-model="form.email"
                        type="email"
                        class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
                        :class="{ 'border-red-500': form.errors.email }"
                    />
                    <p v-if="form.errors.email" class="mt-1 text-sm text-red-600">
                        {{ form.errors.email }}
                    </p>
                </div>

                <!-- Sifre -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">Sifre</label>
                    <input
                        v-model="form.password"
                        type="password"
                        class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
                        :class="{ 'border-red-500': form.errors.password }"
                    />
                    <p v-if="form.errors.password" class="mt-1 text-sm text-red-600">
                        {{ form.errors.password }}
                    </p>
                </div>

                <!-- Sifre Onay -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">Sifre (Tekrar)</label>
                    <input
                        v-model="form.password_confirmation"
                        type="password"
                        class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
                    />
                </div>

                <!-- Rol -->
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-1">Rol</label>
                    <select
                        v-model="form.role_id"
                        class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
                        :class="{ 'border-red-500': form.errors.role_id }"
                    >
                        <option value="">Rol secin...</option>
                        <option v-for="role in roles" :key="role.id" :value="role.id">
                            {{ role.name }}
                        </option>
                    </select>
                    <p v-if="form.errors.role_id" class="mt-1 text-sm text-red-600">
                        {{ form.errors.role_id }}
                    </p>
                </div>

                <!-- Butonlar -->
                <div class="flex items-center gap-4">
                    <Button
                        type="submit"
                        variant="primary"
                        :disabled="form.processing"
                    >
                        <span v-if="form.processing">Kaydediliyor...</span>
                        <span v-else>Kaydet</span>
                    </Button>

                    <Link href="/users" class="text-gray-600 hover:text-gray-800">
                        Iptal
                    </Link>
                </div>
            </form>
        </div>
    </AppLayout>
</template>

useForm'un sundugu ozellikler:

  • form.processing: Submit sirasinda true (buton disable icin)
  • form.errors: Laravel validation hatalari otomatik bind edilir
  • form.reset(): Formu sifirlar
  • form.clearErrors(): Hatalari temizler
  • form.isDirty: Formda degisiklik var mi
  • form.post(), form.put(), form.delete(): HTTP metodlari

Composable Pattern

Vue 3'un en guclu ozelligi composable'lar. Tekrarlanan mantigi extract edip yeniden kullanabilirsiniz.

useFlash

// resources/js/Composables/useFlash.js
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';

export function useFlash() {
    const page = usePage();

    const flash = computed(() => ({
        success: page.props.flash?.success || null,
        error: page.props.flash?.error || null,
        warning: page.props.flash?.warning || null,
        info: page.props.flash?.info || null,
    }));

    return { flash };
}

useDebounce

// resources/js/Composables/useDebounce.js
import { ref, watch } from 'vue';

export function useDebounce(source, delay = 300) {
    const debounced = ref(source.value);
    let timeout;

    watch(source, (newValue) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => {
            debounced.value = newValue;
        }, delay);
    });

    return debounced;
}

useConfirm

// resources/js/Composables/useConfirm.js
import { ref } from 'vue';

export function useConfirm() {
    const isOpen = ref(false);
    const resolveCallback = ref(null);

    function confirm(message = 'Emin misiniz?') {
        isOpen.value = true;
        return new Promise((resolve) => {
            resolveCallback.value = resolve;
        });
    }

    function handleConfirm() {
        isOpen.value = false;
        resolveCallback.value?.(true);
    }

    function handleCancel() {
        isOpen.value = false;
        resolveCallback.value?.(false);
    }

    return { isOpen, confirm, handleConfirm, handleCancel };
}

Shared Data (HandleInertiaRequests)

Her sayfada ihtiyac duydugunuz verileri middleware uzerinden paylasabilirsiniz:

// app/Http/Middleware/HandleInertiaRequests.php
<?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() ? [
                    'id' => $request->user()->id,
                    'name' => $request->user()->name,
                    'email' => $request->user()->email,
                    'avatar' => $request->user()->avatar_url,
                ] : null,
            ],

            'flash' => [
                'success' => fn () => $request->session()->get('success'),
                'error' => fn () => $request->session()->get('error'),
                'warning' => fn () => $request->session()->get('warning'),
            ],

            'app' => [
                'name' => config('app.name'),
                'locale' => app()->getLocale(),
            ],
        ];
    }
}

Authentication

Inertia ile auth cok basit cunku Laravel'in session-based auth'unu kullaniyorsunuz. JWT, token yonetimi, refresh token derdi yok:

// Controller'da auth kontrolu
Route::middleware('auth')->group(function () {
    // Bu route'lara giris yapmis kullanicilar erisebilir
    Route::resource('users', UserController::class);
});

// Login
public function store(LoginRequest $request)
{
    $request->authenticate();
    $request->session()->regenerate();

    return redirect()->intended('/dashboard');
}

// Logout
public function destroy(Request $request)
{
    Auth::guard('web')->logout();
    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect('/');
}

Vue tarafinda auth bilgisine erismek:

<script setup>
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';

const user = computed(() => usePage().props.auth.user);
</script>

<template>
    <div v-if="user">
        Hos geldiniz, {{ user.name }}
    </div>
</template>

Sonuc

Laravel + Vue 3 + Inertia.js kombinasyonu benim icin full-stack gelistirmenin en verimli yolu. Neden?

  1. Tek codebase: API + SPA yerine tek proje
  2. Laravel'in gucu: Routing, validation, auth, middleware -- hepsi Laravel
  3. Vue 3'un esnekligi: Composition API, composable'lar, TypeScript desteği
  4. Inertia'nin sihri: SPA deneyimi, MPA basitligi

Bu stack ile kucuk-orta olcekli projeleri inanilmaz hizli gelistiriyorsunuz. Controller yaz, Inertia render et, Vue component'i olustur -- bitti. REST API yok, CORS yok, token yok.

Buyuk projelerde veya mobil uygulama olan projelerde yine API gerekebilir. Ama sadece web uygulamasi yapiyorsaniz Inertia'yi kesinlikle deneyin.