Багато власників контентних сайтів стикаються з ситуацією: користувачі активно читають матеріали, але не можуть їх зберегти для перегляду пізніше. Відсутність системи закладок знижує повернення та час на сайті. При цьому проста реалізація часто призводить до дублікатів, повільних запитів і незручного UX. Ми впровадили десятки таких систем — від базового toggle до складних колекцій із синхронізацією між пристроями. Пропонуємо перевірене рішення на основі polymorphic зв'язку, яке підходить для будь-яких сутностей: статей, продуктів, вакансій, відео.
Як працює polymorphic зв'язок у базі даних?
Ключове завдання — спроєктувати базу даних так, щоб не плодити окремі таблиці під кожен тип контенту. Polymorphic зв'язок вирішує це однією універсальною таблицею bookmarks:
CREATE TABLE bookmarks (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
bookmarkable_id INTEGER NOT NULL,
bookmarkable_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, bookmarkable_id, bookmarkable_type)
);
CREATE INDEX ON bookmarks(user_id, bookmarkable_type, created_at DESC);
Такий підхід гарантує унікальність закладки для кожного користувача та сутності, а індекс дозволяє швидко отримувати закладки користувача з сортуванням за часом. У реальному проєкті ми зіткнулися з кейсом, коли без індексу запит на 10000 закладок виконувався 2 секунди — після додавання індексу час впав до 10 мілісекунд. Документація Laravel: Polymorphic relations дозволяють моделі належати більш ніж одній іншій моделі через єдиний зв'язок.
Чому polymorphic зв'язок кращий за окремі таблиці?
| Критерій |
Polymorphic |
Окремі таблиці |
| Кількість таблиць |
1 |
N (за кількістю типів) |
| Складність запитів |
Один контролер |
N контролерів |
| Розширюваність |
Додати тип — один рядок |
Нова таблиця + код |
| Продуктивність |
Один індекс |
N індексів |
Polymorphic підхід скорочує обсяг коду в 3–5 разів і спрощує підтримку. При появі нового типу контенту (наприклад, відео) достатньо додати запис у bookmarkable_type — без міграцій БД.
Покроковий план впровадження
- Аналіз і проєктування БД — 0.5 дня. Визначаємо типи сутностей, створюємо міграцію.
- Реалізація Laravel API — 0.5–1 день. Контролер toggle, валідація, документація.
- Створення React компонента — 0.5–1 день. Оптимістичний UI, доступність.
- Інтеграція та тестування — 0.5 дня. Юніт-тести, ручне тестування.
- Деплой і документація — 0.5 дня. Інструкція для розробника, деплой на сервер.
Як реалізовано toggle API на Laravel?
API побудований на одному ендпоінті, який перемикає стан закладки. Використовуємо контролер з методами toggle та index:
class BookmarkController extends Controller
{
public function toggle(Request $request, string $type, int $id): JsonResponse
{
$existing = Bookmark::where([
'user_id' => auth()->id(),
'bookmarkable_type' => $type,
'bookmarkable_id' => $id,
])->first();
if ($existing) {
$existing->delete();
return response()->json(['bookmarked' => false]);
}
Bookmark::create([
'user_id' => auth()->id(),
'bookmarkable_type' => $type,
'bookmarkable_id' => $id,
]);
return response()->json(['bookmarked' => true], 201);
}
public function index(Request $request): JsonResponse
{
$bookmarks = Bookmark::where('user_id', auth()->id())
->when($request->type, fn($q) => $q->where('bookmarkable_type', $request->type))
->with('bookmarkable')
->latest()
->paginate(20);
return response()->json($bookmarks);
}
}
Зверніть увагу: метод index підтримує фільтрацію за типом і пагінацію — це важливо для сторінки «Мої закладки». Без with('bookmarkable') ви отримаєте N+1 запитів, що неприйнятно при високому навантаженні.
Якщо вам потрібна така система — замовте реалізацію під ключ.
Приклад React компонента з оптимістичним UI
function BookmarkButton({ type, id, initialBookmarked }: BookmarkProps) {
const [bookmarked, setBookmarked] = useState(initialBookmarked);
const toggle = async () => {
setBookmarked(!bookmarked);
try {
await api.post(`/api/bookmarks/${type}/${id}/toggle`);
} catch {
setBookmarked(bookmarked);
}
};
return (
<button
onClick={toggle}
aria-label={bookmarked ? 'Видалити із закладок' : 'Додати в закладки'}
aria-pressed={bookmarked}
className={`bookmark-btn ${bookmarked ? 'bookmark-btn--active' : ''}`}
>
{bookmarked ? '🔖' : '🏷️'}
</button>
);
}
Оптимістичний UI: як уникнути затримок?
Оптимістичний update дає відгук за 0 мс, але вимагає обробки помилок. У проєкті для інтернет-магазину ми реалізували 5 кнопок закладок на сторінці — користувачі відзначали, що сайт став «літати» після впровадження такого підходу. Альтернатива — песимістичний UI з loader, але на мобільних пристроях він збільшує perceived latency на 300–500 мс. Використовуйте AbortController для запобігання гонці запитів при швидких кліках.
Процес роботи та терміни
| Етап |
Тривалість |
| Аналіз і проєктування БД |
0.5 дня |
| Розробка API (Laravel) |
0.5–1 день |
| React-компонент та інтеграція |
0.5–1 день |
| Сторінка «Мої закладки» з фільтрацією |
0.5 дня |
| Тестування та деплой |
0.5 дня |
Разом: від 1 до 2 днів на базову реалізацію. Термін може збільшитися при необхідності синхронізації між пристроями або експорту закладок. В одному з проєктів знадобилося додати підтримку WebSocket для миттєвої синхронізації між вкладками — це зайняло додатковий день.
Що входить у роботу
- Документація API (OpenAPI/Swagger)
- Вихідний код з коментарями (Laravel + React)
- Інструкція з розгортання (Docker + CI/CD)
- Гарантія працездатності на обраному хостингу
- 2 тижні підтримки після впровадження
Типові помилки при реалізації закладок
- Відсутність унікального обмеження — користувач може додати одну сторінку кілька разів, що ламає логіку toggle.
- Ігнорування оптимістичного UI — затримка 0.5–1 секунда вбиває відчуття відгуку.
- N+1 запит при отриманні
bookmarkable — обов'язково використовуйте with().
- Неправильний вибір типу поля
bookmarkable_type — використовуйте VARCHAR(50) з константами, щоб уникнути помилок.
Отримайте консультацію з впровадження закладок на ваш сайт — оцінимо ваш проєкт за один день і запропонуємо оптимальну архітектуру. Зв'яжіться з нами для розрахунку вартості проєкту. Наша команда має понад 5 років досвіду в розробці веб-додатків і виконала понад 50 проєктів із системами закладок та колекцій.
Аутентифікація та авторизація: OAuth, JWT, сесії, RBAC, 2FA
На одному проєкті токен JWT із роллю admin: false» міг бути змінений клієнтом на admin: true» — і сервер прийняв його без верифікації підпису. Ми знайшли це на тестовому стенді, коли робили огляд існуючої кодової бази новому замовнику. Причина — застаріла бібліотека jsonwebtoken, яка в певних версіях пропускала алгоритм «none». Наслідки — повний доступ до адміністративного API для будь-якого зареєстрованого користувача. Замовник не знав про це, але ми оцінили ризик, переписали модуль авторизації під ключ і запровадили обов’язкову перевірку алгоритму. Тепер подібних інцидентів немає. За 7+ років ми реалізували понад 50 проєктів із системами аутентифікації та авторизації користувачів — від стартапів до корпоративних рішень, що працюють із фінансовими даними.
Чому JWT не варто зберігати в localStorage?
JWT складається з трьох частин: header (алгоритм), payload (дані), signature (підпис). Підпис верифікує, що payload не змінено. Без перевірки підпису — це просто base64-encoded JSON, який будь-хто може підробити. Помилки, які бачимо в коді регулярно:
- Зберігання в localStorage. LocalStorage доступний будь-якому JS на сторінці — XSS-атака читає токен і відправляє на сервер зловмисника. Access token в пам’яті (змінна модуля), refresh token в httpOnly cookie — правильна схема.
- Довгоживучі access-токени. Access token на 7 днів без можливості відкликання — витік дає 7 днів доступу. Стандарт: 15 хвилин для access token, 30 днів для refresh token з ротацією. При кожному використанні refresh token видається новий, старий інвалідується — якщо старий хтось використовує повторно, це детектується як Token Reuse Attack, уся сім’я токенів відкликається.
- Зберігання секретних даних у payload. JWT payload не зашифровано, лише підписано — його видно в base64. Паролі, платіжні дані, особиста інформація — не в JWT. Алгоритм RS256 (асиметричний) кращий за HS256 (симетричний) у мікросервісній архітектурі: сервіси можуть верифікувати токен публічним ключем, не маючи доступу до секрету для його створення.
Як обрати між сесіями та токенами?
Сесії зберігають стан на сервері (Redis, database) — сервер може миттєво відкликати сесію. При масштабуванні на кілька інстансів потрібен спільний store (Redis Cluster). Cookie з session ID — httpOnly, Secure, SameSite=Strict.
Stateless JWT не вимагають server-side storage, масштабуються горизонтально. Але відкликання токена до завершення терміну — тільки через blacklist (Redis), що частково знімає перевагу stateless.
| Параметр |
Сесії (серверний стан) |
JWT (stateless) |
| Відкликання |
миттєве (видалити запис у Redis) |
лише через blacklist, потребує storage |
| Масштабування |
потрібен спільний Redis |
горизонтальне без додаткових компонентів |
| Безпека XSS |
токен у httpOnly cookie захищений |
при зберіганні в localStorage — ризик |
| Складність реалізації |
проста (сесійний middleware) |
вища (управління refresh, ротація) |
Для більшості веб-додатків сесії простіші та безпечніші. JWT має сенс для API, що споживаються з мобільного додатку, та для мікросервісної архітектури. Оцініть ваш сценарій — ми допоможемо обрати правильний підхід.
OAuth 2.0 та OpenID Connect
OAuth 2.0 — протокол делегованої авторизації, не аутентифікації. «Увійти через Google» — це OpenID Connect поверх OAuth 2.0, який додає id_token з даними користувача. Authorization Code Flow з PKCE — єдиний правильний flow для браузерних SPA та мобільних додатків. Implicit Flow застарів і небезпечний. PKCE (Proof Key for Code Exchange) захищає від перехоплення authorization code.
Реалізація OAuth сервера: не пишемо з нуля. Keycloak (open source, self-hosted), Auth0, Okta — готові рішення. Laravel Passport або Laravel Sanctum для серверних додатків. NextAuth.js для Next.js — підтримує 50+ провайдерів з коробки. Для B2B продуктів з корпоративними клієнтами — SAML 2.0 SSO. Корпоративні IT-відділи часто вимагають його замість OAuth. @boxyhq/saml-jackson — node.js бібліотека для SAML → OAuth2 адаптера.
RBAC, ABAC, ReBAC — що і коли застосовувати
Role-Based Access Control — у користувача є ролі, у ролей — права. Проста реалізація: user → roles → permissions. Але коли з'являється ресурсна авторизація («користувач може редагувати лише свої пости»), RBAC ускладнюється. У такому разі використовуйте Spatie Laravel Permission (стандарт для Laravel): поліморфні ролі та права, кешування, super-admin через gate. Інтеграція з Eloquent — $user->can('edit posts'), $user->hasRole('editor').
ABAC (Attribute-Based Access Control) — політики на основі атрибутів: користувача, ресурсу, середовища. Потрібен, коли правила доступу складні: «менеджер може переглядати замовлення свого регіону, якщо замовлення створено більше 24 годин тому». Casbin — популярна cross-language бібліотека для ABAC.
ReBAC (Relationship-Based Access Control) — Google Zanzibar model. Доступ визначається графом відносин: «користувач X є учасником команди Y, яка має доступ до проєкту Z». OpenFGA — open source реалізація від Okta.
Як ми це робимо: кейс із впровадження 2FA
Проєкт — платіжний шлюз для маркетплейсу. Потрібно було захистити доступ до операцій виводу коштів. Ми спроєктували систему:
- Основний пароль замінили на комбінацію пароль + TOTP (Google Authenticator). Використали
otplib (Node.js) для генерації та верифікації кодів.
- Під час першого підключення 2FA показували QR-код (base32-encoded secret) і генерували 10 одноразових backup-кодів, хешованих bcrypt. Відображали коди лише один раз.
- Secret для TOTP зберігали у зашифрованому вигляді в базі даних (AES-256-GCM, ключ у AWS KMS).
- На стороні фронтенду інтегрували
@simplewebauthn/browser для passkeys — біометрична аутентифікація як альтернатива паролю. Публічний ключ зберігали на сервері, private key на пристрої користувача.
- Результат: час на логін зріс на 5 секунд, але кількість зламаних акаунтів упала до нуля за пів року роботи. Гарантія безпеки — на рівні OWASP ASVS Level 2.
Також варто зазначити, що TOTP значно надійніше за SMS-верифікацію через SIM-swapping, тому для фінансових даних ми рекомендуємо TOTP або апаратні ключі.
Типові вразливості, які ми знаходимо
- Broken Object Level Authorization (BOLA/IDOR): /api/orders/12345 повертає замовлення без перевірки, чи належить воно поточному користувачу. Найпоширеніша вразливість API за OWASP. Кожен запит до ресурсу — перевірка через $user->can('view', $order).
- Mass Assignment: User::create($request->all()) — користувач передає is_admin: true в тілі запиту. Laravel вирішує через $fillable / $guarded, але часто забувають.
- Небезпечний CORS: Access-Control-Allow-Origin: * на API з авторизацією по cookie — credentials не передаються з wildcard origin, але якщо хтось зробив Allow-Credentials: true + Allow-Origin: * — це діра.
Penetration testing обов’язковий для продуктів з фінансовими даними або персональними даними користувачів. Ми проводимо аудит коду й інфраструктури на етапі приймання.
Що входить у роботу
Ми передаємо замовнику:
- Документацію архітектури авторизації (flow діаграми, опис токенів, політик доступу).
- Репозиторій із вихідним кодом, покритий unit- та integration-тестами.
- Конфігурацію для CI/CD (GitHub Actions/ GitLab CI) із перевірками безпеки.
- Доступи до середовищ (staging, production) із правами адміністратора.
- Інструкцію з експлуатації та супроводу.
- Підтримку після впровадження — 2 тижні безкоштовних консультацій.
Терміни та вартість
Базова аутентифікація (email/password + OAuth + JWT/сесії): 1–3 тижні. RBAC з детальними політиками доступу: 2–4 тижні. 2FA (TOTP + SMS): 1–2 тижні. WebAuthn/Passkeys: 2–3 тижні. Повна система аутентифікації для SaaS з multi-tenancy: 4–8 тижнів.
Вартість розраховується індивідуально залежно від обсягу та складності. Замовте консультацію — оцінимо ваш проєкт безкоштовно. Отримайте гарантію безпеки вашої авторизації користувачів.