Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Inertia й Laravel

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Inertia - «клей» між серверним фреймворком (Laravel) і фронтенд-фреймворком (Vue, React, Svelte). Вона дає досвід SPA, але без окремого API.

Класична SPA з API:

  • Laravel - REST/JSON API, автентифікація токенами чи Sanctum;
  • Vue - власний роутер, стор, запити до API, обробка помилок;
  • дві кодові бази, дублювання валідації, контрактів, маршрутів.

Inertia:

  • маршрути, контролери, middleware, авторизація, валідація - звичайні в Laravel;
  • контролер замість Blade-шаблону повертає Vue-компонент сторінки з даними:
public function index()
{
    return Inertia::render('Users/Index', [
        'users' => User::query()->latest()->paginate(20),
    ]);
}
<!-- resources/js/pages/Users/Index.vue -->
<script setup>
defineProps({ users: Object });
</script>

Як це працює:

  1. перше завантаження - звичайний HTML-відгук: кореневий шаблон з даними сторінки в JSON;
  2. переходи через <Link> - запит XHR із заголовком X-Inertia. Сервер бачить його й повертає JSON - назву компонента й props;
  3. Inertia підміняє компонент сторінки без перезавантаження.

Чого в Inertia немає й не треба: клієнтського роутера (Vue Router), API для сторінок, сторів для серверних даних (дані приходять props).

Коли Inertia добре підходить: застосунок і фронтенд роблять одна команда, все в одному репозиторії Laravel - адмінки, SaaS, кабінети.

Коли ні: API потрібен ще й для мобільного застосунку чи сторонніх клієнтів; фронтенд і бекенд розробляють і деплоять незалежно.

Inertia 3 (актуальна версія) прибрала залежність від Axios (власний HTTP-клієнт), спростила SSR у розробці й вимагає Laravel 11+ та PHP 8.2+. Стартові набори Laravel з Vue побудовані саме на Inertia.

Докладніше в документації: Inertia: як це працює

Inertia::render() приймає назву компонента сторінки й масив props - даних, які отримає компонент.

use Inertia\Inertia;

public function show(Post $post)
{
    return Inertia::render('Posts/Show', [
        'post' => PostResource::make($post->load('author')),
        'comments' => $post->comments()->latest()->limit(20)->get(['id', 'body', 'created_at']),
        'canEdit' => request()->user()?->can('update', $post) ?? false,
    ]);
}
<script setup>
const props = defineProps({
  post: Object,
  comments: Array,
  canEdit: Boolean,
});
</script>

<template>
  <h1>{{ post.title }}</h1>
  <button v-if="canEdit">Редагувати</button>
</template>

Як перетворюються дані:

  • моделі й колекції - через toArray() (з урахуванням $hidden і $appends);
  • API Resources - через toResponse(), тож PostResource дає контрольований формат;
  • замикання (fn () => ...) обчислюються лише тоді, коли prop справді потрібен (часткові перезавантаження);
  • назва компонента може бути backed enum - для типобезпечних посилань на сторінки.

Головне правило безпеки: усі props видно в браузері. Вони потрапляють у JSON сторінки, який будь-хто бачить у вихідному коді чи вкладці Network. Модель, передана цілком, віддасть усі свої поля: email, телефон, службові прапорці, хеш токена, якщо його забули сховати.

Тому:

  • передавати лише потрібні поля: ->only(['id', 'name']), get(['id', 'title']) чи API Resource;
  • не покладатися на те, що «Vue це не показує» - не показаний у шаблоні prop все одно є в JSON;
  • права передавати готовими булевими значеннями (canEdit), а перевіряти - на сервері в кожній дії.

Сторінка без контролера: Route::inertia('/about', 'About').

Докладніше в документації: Inertia: відповіді

useForm - помічник Inertia для форм: зберігає дані, стан відправки, помилки валідації й прогрес завантаження.

<script setup>
import { useForm } from '@inertiajs/vue3';

const form = useForm({
  title: '',
  body: '',
  cover: null,
});

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

<template>
  <form @submit.prevent="submit">
    <input v-model="form.title">
    <p v-if="form.errors.title">{{ form.errors.title }}</p>

    <textarea v-model="form.body" />

    <input type="file" @input="form.cover = $event.target.files[0]">
    <progress v-if="form.progress" :value="form.progress.percentage" max="100" />

    <button :disabled="form.processing">Опублікувати</button>
  </form>
</template>

Що дає useForm:

  • методи відправки: get, post, put, patch, delete;
  • form.errors - помилки валідації Laravel заповнюються автоматично;
  • form.processing - чи йде відправка (захист від подвійного кліку);
  • form.isDirty - чи змінювали форму; wasSuccessful, recentlySuccessful (дві секунди після успіху - для «Збережено»);
  • reset(), clearErrors(), resetAndClearErrors(), defaults();
  • transform() - змінити дані перед відправкою.

Файли. Якщо в даних є файл, Inertia сама перетворить їх на FormData. Але для put/patch з файлами PHP не розбирає multipart - відправляють post з _method: 'put'.

Валідація на сервері - звичайна:

public function store(Request $request)
{
    $validated = $request->validate(['title' => 'required|max:255', 'body' => 'required']);
    // при помилці Laravel поверне назад, а Inertia покладе помилки у form.errors
}

Корисне: useForm('CreatePost', {...}) з ключем зберігає дані форми в історії браузера - повернувшись «Назад», користувач не втратить введене; паролі виключають через dontRemember('password').

Простіша альтернатива - компонент <Form action="/posts" method="post">, що працює як звичайна HTML-форма з полями за атрибутом name.

Докладніше в документації: Inertia: форми

Звичайний <a href> завантажує сторінку повністю: HTML, CSS, JavaScript, ініціалізація Vue. <Link> з @inertiajs/vue3 перехоплює клік і робить Inertia-візит: запит повертає лише JSON з назвою компонента й props, а сторінка підміняється без перезавантаження.

<script setup>
import { Link } from '@inertiajs/vue3';
</script>

<template>
  <Link href="/posts">Блог</Link>
  <Link :href="`/posts/${post.id}`" prefetch>{{ post.title }}</Link>
</template>

Можливості:

  • інші HTTP-методи:
<Link href="/logout" method="post" as="button">Вийти</Link>
<Link :href="`/posts/${post.id}`" method="delete" as="button">Видалити</Link>

Для не-GET методів варто рендерити кнопку (as="button"): посилання, що виконує POST, порушує очікування доступності й «відкрити в новій вкладці».

  • дані запиту: :data="{ status: 'draft' }";
  • preserve-scroll - не прокручувати на початок (фільтри, пагінація посеред сторінки);
  • preserve-state - зберегти локальний стан компонента (введене в поле пошуку);
  • replace - замінити запис в історії, а не додати;
  • :only="['users']" - часткове перезавантаження лише потрібних props;
  • prefetch - завантажити дані заздалегідь при наведенні (75 мс за замовчуванням), prefetch="click" - на натисканні, prefetch="mount" - одразу; кеш на 30 секунд (cache-for).

Активне посилання - порівняння з $page.url чи usePage().url.

Чого не робити:

  • <Link> на зовнішні сайти - для них звичайний <a>;
  • <Link> на маршрути Laravel, що повертають не Inertia-відповідь (файл для завантаження, сторінку без Inertia) - Inertia покаже модальне вікно з HTML. Для таких адрес - звичайне посилання або Inertia::location() на сервері;
  • вихід з облікового запису через GET - лише method="post".

Докладніше в документації: Inertia: посилання

Деякі дані потрібні майже на кожній сторінці: поточний користувач у шапці, назва застосунку, налаштування. Передавати їх у кожному контролері незручно - для цього є спільні дані в middleware HandleInertiaRequests:

class HandleInertiaRequests extends Middleware
{
    public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'appName' => config('app.name'),
            'auth' => [
                'user' => fn () => $request->user()?->only('id', 'name', 'avatar_url'),
            ],
        ];
    }
}
<script setup>
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';

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

Спільні дані зливаються з props сторінки - тож простір імен (auth.user) захищає від зіткнень із props контролерів.

Головні правила:

  • спільні дані йдуть з кожною відповіддю - кожен перехід і кожне часткове перезавантаження. Важкий запит тут - податок на всі сторінки. Замикання (fn () => ...) обчислюються лише тоді, коли prop справді потрібен;
  • рідко змінні дані (список країн, налаштування) - Inertia::once() чи метод shareOnce(): клієнт запам'ятовує значення й не отримує його з кожною відповіддю;
  • усе видно в браузері. $request->user() без only() віддасть усі атрибути моделі в JSON кожної сторінки - email, телефон, службові прапорці, усе, що не в $hidden. Передавайте явний перелік полів чи ресурс;
  • ролі й права - готовими булевими значеннями для інтерфейсу (can.manageUsers), а не списком усіх дозволів системи.

Валідаційні помилки адаптер Laravel уже додає в спільні дані (errors), а флеш-дані в Inertia 3 мають окремий механізм Inertia::flash().

Версія ассетів визначається в тому самому middleware (метод version()) - для Vite вона обчислюється автоматично.

Докладніше в документації: Inertia: спільні дані

У класичній SPA сервер на помилку валідації повертає 422 з JSON, а клієнт розкладає помилки по полях. Inertia працює інакше - так само, як звичайні форми Laravel:

  1. форма відправляється Inertia-запитом;
  2. $request->validate() не проходить - Laravel перенаправляє назад і кладе помилки в сесію;
  3. адаптер Inertia додає помилки з сесії в props сторінки (errors);
  4. Inertia бачить непорожній page.props.errors і вважає відправку невдалою - викликає onError, а не onSuccess.

Відповідей 422 Inertia-застосунок не генерує взагалі.

public function store(Request $request)
{
    $request->validate([
        'email' => ['required', 'email', 'unique:users'],
        'name' => ['required', 'max:100'],
    ]);
    // ...
}

З useForm помилки потрапляють у form.errors автоматично. Без нього - через prop:

<script setup>
defineProps({ errors: Object });
</script>
<template>
  <p v-if="errors.email">{{ errors.email }}</p>
</template>

Введене не губиться: після перенаправлення назад Inertia за замовчуванням зберігає стан компонента сторінки, тож поля не очищаються.

Error bags - для сторінки з кількома формами з однаковими назвами полів (дві форми з полем email):

router.post('/companies', data, { errorBag: 'createCompany' });
router.post('/users', data, { errorBag: 'createUser' });
// помилки прийдуть у page.props.errors.createCompany / .createUser

З useForm error bags не потрібні - помилки й так прив'язані до екземпляра форми.

Усі помилки поля, а не лише перша. За замовчуванням адаптер Laravel повертає по одній помилці на поле. Для всіх - protected $withAllErrors = true; у HandleInertiaRequests; тоді кожне поле містить масив рядків.

Часткові перезавантаження й помилки. errors передається завжди, тож порожній набір помилок з сервера затре клієнтські. Щоб зберегти помилки під час router.reload({ only: [...] }), у Inertia 3 є опція preserveErrors: true.

Попередня валідація (до відправки) - Laravel Precognition, підтримку якої вбудовано у форми Inertia.

Докладніше в документації: Inertia: валідація

Сторінка списку користувачів має props users і companies (для фільтра). Користувач змінює фільтр - потрібні нові users, а companies не змінилися. Отримувати й перераховувати все - марна робота.

Часткове перезавантаження - запит лише вибраних props тієї самої сторінки:

import { router } from '@inertiajs/vue3';

router.reload({ only: ['users'], data: { company: 3 } });
router.reload({ except: ['companies'] });
<Link href="/users?active=1" :only="['users']">Активні</Link>

Працює лише для візитів на той самий компонент сторінки.

Щоб сервер справді не виконував зайве - props мають бути замиканнями:

return Inertia::render('Users/Index', [
    'users' => fn () => User::query()->filter(request()->all())->paginate(),
    'companies' => fn () => Company::orderBy('name')->get(['id', 'name']),
]);

Без замикання Company::all() виконається на кожен запит, навіть якщо результат не потрапить у відповідь.

Типи props:

Запис Звичайний візит Часткове перезавантаження Обчислюється
User::all() так за запитом завжди
fn () => User::all() так за запитом лише коли потрібен
Inertia::optional(fn () => ...) ні за запитом лише коли потрібен
Inertia::always(...) так завжди завжди
  • Inertia::optional() - prop, який сторінка отримує лише на явний запит (only): дані для вкладки, яку відкривають рідко. У Inertia 3 він замінив видалений Inertia::lazy();
  • Inertia::always() - потрапляє навіть у часткові перезавантаження (так передаються помилки валідації).

Inertia 3: only/except підтримують крапкову нотацію для вкладених props (only: ['auth.notifications']), а optional, defer, merge працюють на будь-якій глибині масиву.

Поєднання з once(): Inertia::optional(fn () => ...)->once() - завантажити один раз і пам'ятати на клієнті між переходами.

Докладніше в документації: Inertia: часткові перезавантаження

Повідомлення «Користувача створено» має з'явитися один раз після дії й не повторюватися. Раніше в Inertia-застосунках його передавали через session()->flash() і спільні дані middleware. У Inertia 3 для цього є окремий механізм - флеш-дані.

На сервері:

use Inertia\Inertia;

public function store(StoreUserRequest $request)
{
    $user = User::create($request->validated());

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Користувача створено']);

    return back();
    // або коротше: return Inertia::flash('newUserId', $user->id)->back();
}

flash() можна поєднувати і з render(): Inertia::render(...)->flash('highlight', $id).

На клієнті - у page.flash, окремо від props:

<script setup>
import { usePage } from '@inertiajs/vue3';
const page = usePage();
</script>

<template>
  <div v-if="page.flash.toast" class="toast">{{ page.flash.toast.message }}</div>
</template>

Або реагувати подією - в одному місці макета:

router.on('flash', (event) => {
  if (event.detail.flash.toast) showToast(event.detail.flash.toast);
});

router.post('/users', data, {
  onFlash: ({ newUserId }) => highlight(newUserId),
});

Чим кращий за флеш через сесію в props:

  • не потрапляє в історію браузера. Props зберігаються в стані історії: користувач натиснув «Назад» - і старе «Збережено» знову на екрані. Флеш-дані в історію не пишуться;
  • не змішується з props сторінки;
  • працює однаково з редиректом і без нього: middleware сам зберігає флеш у сесію при перенаправленні.

Що варто знати:

  • обробники router.on(...), зареєстровані в компонентах, треба знімати при демонтажі (функція, яку повертає on), інакше в непостійних макетах вони накопичуються;
  • флеш - для одноразових повідомлень, а не для даних, які мають бути на сторінці після оновлення;
  • як і props, флеш-дані видно в браузері - лише публічна інформація.

Докладніше в документації: Inertia: флеш-дані

Сторінка з важкою статистикою віддається повільно: сервер не відправить відповідь, доки не порахує все. Deferred props дають змогу показати сторінку одразу, а дорогі дані довантажити окремим запитом.

return Inertia::render('Dashboard', [
    'user' => $request->user()->only('id', 'name'),
    'orders' => Inertia::defer(fn () => Order::recentFor($request->user())),
    'stats' => Inertia::defer(fn () => Stats::heavy(), 'analytics'),
    'chart' => Inertia::defer(fn () => Stats::chart(), 'analytics'),
]);
<script setup>
import { Deferred } from '@inertiajs/vue3';
defineProps({ orders: Array, stats: Object, chart: Object });
</script>

<template>
  <Deferred data="orders">
    <template #fallback><OrdersSkeleton /></template>
    <OrdersTable :orders="orders" />
  </Deferred>

  <Deferred :data="['stats', 'chart']" #default="{ reloading }">
    <template #fallback><div class="h-64 animate-pulse" /></template>
    <Stats :class="{ 'opacity-50': reloading }" :stats="stats" :chart="chart" />
  </Deferred>
</template>

Групи: за замовчуванням усі відкладені props довантажуються одним запитом після рендеру. Другий аргумент ('analytics') - назва групи: різні групи йдуть паралельними запитами, щоб повільна статистика не затримувала замовлення.

Помилки: Inertia::defer(fn () => ..., rescue: true) - виняток не ламає відповідь, prop пропускається, а помилка йде в звіт; у <Deferred> для цього слот #rescue з кнопкою повтору.

Once props - для даних, що рідко змінюються (тарифи, країни, курси валют):

'plans' => Inertia::once(fn () => Plan::all()),
'rates' => Inertia::once(fn () => ExchangeRate::all())->until(now()->addDay()),

Клієнт запам'ятовує значення, а наступні відповіді сторінок з цим prop його не обчислюють і не передають. fresh() примушує оновити, as('roles') дає спільний ключ для різних сторінок. Поєднується з відкладеними: Inertia::defer(...)->once().

Що враховувати:

  • відкладене - не для SEO-важливого вмісту: у початковому HTML його немає;
  • кожна група - окремий запит з повним стеком middleware;
  • once props забуваються при переході на сторінку без них - і на наступній сторінці обчислюються знову;
  • заглушка (fallback) з тим самим розміром, що й вміст, - щоб сторінка не «стрибала».

Докладніше в документації: Inertia: відкладені props

Inertia за замовчуванням замінює prop новим значенням. Для «Завантажити ще» потрібно дописувати нові елементи до вже показаних - для цього є злиття props.

public function index(Request $request)
{
    return Inertia::render('Feed', [
        'posts' => Inertia::merge(
            Post::query()->latest()->paginate(15)
        )->append('data', matchOn: 'id'),
    ]);
}
<script setup>
import { router } from '@inertiajs/vue3';
const props = defineProps({ posts: Object });

function loadMore() {
  router.reload({
    only: ['posts'],
    data: { page: props.posts.current_page + 1 },
    preserveUrl: true,
  });
}
</script>

<template>
  <PostCard v-for="post in posts.data" :key="post.id" :post="post" />
  <button v-if="posts.next_page_url" @click="loadMore">Завантажити ще</button>
</template>

Як працює злиття:

  • лише при часткових перезавантаженнях. Звичайний візит на сторінку завжди замінює prop повністю;
  • Inertia::merge($items) - дописати в кінець масиву на верхньому рівні; ->prepend() - на початок (нові повідомлення чату);
  • ->append('data') - для пагінатора: дописати лише масив data, а current_page, next_page_url - замінити;
  • matchOn: 'id' - якщо елемент з таким id вже є, він оновлюється, а не дублюється. Важливо, коли за час прокрутки на початок стрічки додалися нові записи й «сторінки» зсунулися;
  • Inertia::deepMerge() - глибоке злиття всієї структури.

Нескінченна прокрутка - готове рішення на тому самому механізмі: Inertia::scroll(fn () => Post::paginate()) на сервері (сам налаштовує злиття й метадані пагінації, працює з paginate, simplePaginate і cursorPaginate) і компонент <InfiniteScroll> на клієнті, що довантажує сторінки при прокрутці.

Пастки:

  • пагінація через OFFSET зсувається, якщо записи додаються: сторінка 2 повторить останній елемент сторінки 1. matchOn прибирає дублікати, але курсорна пагінація (cursorPaginate) надійніша;
  • пам'ять: після сотні сторінок масив з тисяч елементів живе в props і в стані історії браузера;
  • повернення «Назад» відновлює весь довантажений список з історії - добре для користувача, але великий стан історії браузери можуть обмежувати;
  • preserveUrl - щоб адреса не ставала ?page=7, а оновлення сторінки не відкривало сьому сторінку без перших шести.

Докладніше в документації: Inertia: злиття props

Inertia не змінює модель безпеки Laravel - маршрути, middleware, політики працюють як завжди. Ризики з'являються там, де дані йдуть у браузер.

1. Усі props публічні. Кожен prop і всі спільні дані - у JSON сторінки, який видно у вихідному коді й вкладці Network. Типові витоки:

  • 'user' => $request->user() у спільних даних - усі атрибути моделі на кожній сторінці;
  • колекції моделей цілком (User::all()) - email, телефони, службові поля всіх користувачів;
  • «прихований» prop, який шаблон просто не показує.

Ліки - явні поля (only(), get([...])), API Resources, перевірка JSON сторінок у тестах.

2. Авторизація лише на сервері. v-if="can.delete" ховає кнопку, але запит DELETE /posts/5 можна відправити й без неї. Кожна дія контролера перевіряє права ($this->authorize(), політики); у props передаються лише булеві прапорці для інтерфейсу.

3. Історія браузера. Inertia зберігає props сторінок у стані історії, щоб «Назад» працював миттєво. Після виходу з облікового запису інша людина за тим самим комп'ютером натисне «Назад» - і побачить сторінки з персональними даними з історії, без запиту до сервера.

Шифрування історії:

// глобально: config/inertia.php - history.encrypt => true
// або для окремих маршрутів
Route::middleware('inertia::encrypt')->group(function () { /* ... */ });

// при виході - зробити старі записи нечитабельними
public function logout(Request $request)
{
    Auth::logout();
    Inertia::clearHistory();
    return redirect('/');
}

Inertia шифрує стан сторінки ключем у sessionStorage (Web Crypto API). clearHistory() змінює ключ - старі записи розшифрувати неможливо, і Inertia запитує сторінку з сервера, де вже діє перевірка автентифікації. Потребує HTTPS.

4. CSRF. Inertia покладається на cookie XSRF-TOKEN Laravel; помилку 419 (сесія завершилася) варто обробити - перезавантажити сторінку чи показати повідомлення.

5. XSS. Vue екранує {{ }}, але v-html з даними користувача - діра, як і в будь-якому Vue-застосунку. При SSR дані сторінки вбудовуються в HTML - адаптер робить це безпечно (<script type="application/json"> у v3), власні вставки треба екранувати так само.

Докладніше в документації: Inertia: шифрування історії

Без SSR перше завантаження Inertia-сторінки - це майже порожній HTML з даними в JSON, а вміст з'являється після завантаження й виконання JavaScript. Пошукові системи й соцмережі (прев'ю посилань) бачать порожню сторінку, а користувач - білий екран довше.

SSR рендерить Vue-компонент сторінки на сервері: у HTML одразу є вміст, а в браузері Vue гідрує його - підключається до готової розмітки.

Як це влаштовано: рендеринг виконує Node.js-процес, а Laravel надсилає йому дані сторінки й отримує HTML. На сервері потрібен Node.

Inertia 3 спростила налаштування:

  • плагін @inertiajs/vite налаштовує SSR автоматично;
  • у розробці SSR працює просто з npm run dev - без окремої збірки й окремого сервера;
  • у продакшені - збірка vite build && vite build --ssr і процес php artisan inertia:start-ssr (під наглядом supervisor), за потреби з кластеризацією на кілька потоків.

Вимкнути SSR для частини застосунку (адмінка, де SEO не потрібне): властивість $withoutSsr у middleware чи Inertia::withoutSsr(['admin/*']); повністю - ssr.enabled => false у конфігурації або Inertia::disableSsr().

Код, що працює лише в браузері (window, localStorage, бібліотеки з DOM), на сервері падає. Його виконують в onMounted або загортають у компонент WhenMounted, що рендерить вміст лише після монтування в браузері.

Версіонування ассетів. SPA живе в браузері годинами - після деплою користувач продовжує працювати зі старим JavaScript, який може не відповідати новим props. Inertia передає версію ассетів у кожному запиті; якщо версія на сервері змінилася, Inertia робить повне перезавантаження сторінки замість звичайного візиту, і браузер отримує нові файли.

  • з Vite версія обчислюється автоматично (метод version() у HandleInertiaRequests);
  • з Inertia 3.6 зміна версії, помічена у фоновому запиті (опитування, router.reload()), не перезавантажує сторінку - щоб не знищити незбережені дані, яких користувач не чекав втратити.

Пастка деплою: SSR-процес Node треба перезапускати при кожному деплої, інакше він рендеритиме старими компонентами.

Докладніше в документації: Inertia: серверний рендеринг