Питання на співбесіді: 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>
Як це працює:
- перше завантаження - звичайний HTML-відгук: кореневий шаблон з даними сторінки в JSON;
- переходи через
<Link>- запит XHR із заголовкомX-Inertia. Сервер бачить його й повертає JSON - назву компонента й props; - Inertia підміняє компонент сторінки без перезавантаження.
Чого в Inertia немає й не треба: клієнтського роутера (Vue Router), API для сторінок, сторів для серверних даних (дані приходять props).
Коли Inertia добре підходить: застосунок і фронтенд роблять одна команда, все в одному репозиторії Laravel - адмінки, SaaS, кабінети.
Коли ні: API потрібен ще й для мобільного застосунку чи сторонніх клієнтів; фронтенд і бекенд розробляють і деплоять незалежно.
Inertia 3 (актуальна версія) прибрала залежність від Axios (власний HTTP-клієнт), спростила SSR у розробці й вимагає Laravel 11+ та PHP 8.2+. Стартові набори Laravel з Vue побудовані саме на 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').
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.
Звичайний <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".
Деякі дані потрібні майже на кожній сторінці: поточний користувач у шапці, назва застосунку, налаштування. Передавати їх у кожному контролері незручно - для цього є спільні дані в 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 вона обчислюється автоматично.
У класичній SPA сервер на помилку валідації повертає 422 з JSON, а клієнт розкладає помилки по полях. Inertia працює інакше - так само, як звичайні форми Laravel:
- форма відправляється Inertia-запитом;
$request->validate()не проходить - Laravel перенаправляє назад і кладе помилки в сесію;- адаптер Inertia додає помилки з сесії в props сторінки (
errors); - 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.
Сторінка списку користувачів має 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, флеш-дані видно в браузері - лише публічна інформація.
Сторінка з важкою статистикою віддається повільно: сервер не відправить відповідь, доки не порахує все. 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 за замовчуванням замінює 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 не змінює модель безпеки 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), власні вставки треба екранувати так само.
Без 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 треба перезапускати при кожному деплої, інакше він рендеритиме старими компонентами.