0% прочитано

Керування видимістю публікацій у Laravel: як Draft може випадково стати доступним публічно

Чернетка, запланована публікація і опублікований матеріал здаються простими станами, доки публічний маршрут, права на попередній перегляд, SEO, аналітика, пошук і sitemap не починають кожен окремо вирішувати, чи доступний контент. На реальному прикладі Tips у ICanUp я показую, як зберегти єдине правило публікації для всієї системи й не допустити ситуації, коли попередній перегляд фактично перетворюється на справжню публікацію.

26 вересня 2026 р. 11 хв читанняЛаравель

У ICanUp видимість публікації перестала зводитися до простої перевірки status === published, щойно з’явилися відкладена публікація, попередній перегляд для редактора, локалізація, SEO, публічні стрічки та аналітика.

Небезпечна ситуація може виникнути майже непомітно: один маршрут перевіряє статус, інша частина коду - лише дату публікації, а стрічка або SEO використовують ще одну окрему умову. У результаті матеріал може залишатися чернеткою в адмінпанелі й водночас уже бути доступним у публічній частині сайту.

Для Tips я залишила одне доменне правило видимості, від якого залежать публічний доступ, попередній перегляд, URL у ресурсі, SEO та всі побічні дії, пов’язані з публікацією.

Запланована публікація тут не є окремим статусом. Це матеріал зі статусом `Published`, у якого `published_at` вказує на майбутню дату. Якщо перевіряти лише статус, такий матеріал стане доступним публічно раніше запланованого часу.

Видимість - це доменне правило, а не окрема умова в контролері

Для Tip є одна відповідь на запитання: чи має цей матеріал бути доступним публічно саме зараз?

Це правило враховує м’яке видалення, статус, час публікації, наявність Topic і заголовка для поточної локалі.

Це важливо, тому що правило публічної видимості потрібне не лише одному контролеру. Його використовують списки матеріалів, Explorer, сторінки Topic, пошук, пов’язаний контент, sitemap, сторінка самого матеріалу та аналітика.

PHP - Canonical public visibility
public function isPubliclyVisible(?string $locale = null): bool{    $locale ??= app()->getLocale();     return ! $this->trashed()        && $this->status === PostStatusEnum::PUBLISHED        && $this->published_at !== null        && ! $this->published_at->isFuture()        && $this->topic()->exists()        && filled(            $this->translationForLocale(                'title',                $locale,            )        );}

Чотири стани, але не чотири статуси

Стан

Контракт

Draft

status = draft. Не public. Preview можливий тільки з окремим permission.

Scheduled

status = published, але published_at у майбутньому. Не public, але preview можливий.

Published

Publication time настав, запис не deleted, Topic існує, localized title заповнений. Public.

Unavailable

Deleted, wrong locale, missing Topic або інша неповна public-умова. Не public і не preview.

Публічна видимість відповідає на запитання: «Чи може цей матеріал бачити звичайний відвідувач?» Попередній перегляд відповідає на інше запитання: «Чи може уповноважений редактор побачити матеріал, який ще не опублікований?» Це два різні правила доступу.

Попередній перегляд не змінює стан публікації.

PHP - Public access and preview are separate
public function previewState(): ?string{    if ($this->trashed()) {        return null;    }     if (        $this->status        === PostStatusEnum::DRAFT    ) {        return 'draft';    }     return $this->status        === PostStatusEnum::PUBLISHED        && $this->published_at?->isFuture()

canBeViewed() навмисно має ширші правила, ніж isPubliclyVisible(). Доступ можливий з двох різних причин: матеріал уже опублікований або користувач має право на попередній перегляд підтримуваного неопублікованого стану.

Право доступу не змінює доменний стан матеріалу. Чернетка не стає опублікованою, запланований матеріал не публікується раніше визначеного часу, а видалений Tip не отримує права на попередній перегляд.

Preview - це дозвіл побачити ще не опублікований матеріал, а не виняток, який робить його публічним.

Право доступу перевіряється на вході, а доменне правило визначає видимість

PHP - Pass authorization into the visibility contract
public function __invoke(    Request $request,    Tip $tip,): Response {    $canPreview = $request        ->user()        ?->can(            PermissionEnum::TIPS_PREVIEW->value        ) ?? false;     $tip = $this->tipService        ->getTip(            $tip,            $canPreview,        ); 

Контролер сам не вирішує, що означають стани Draft або Scheduled. Він лише перевіряє право користувача на TIPS_PREVIEW.

Далі публічний сервіс передає результат цієї перевірки доменному правилу. Якщо canBeViewed() повертає false, користувач отримує 404.

Самого факту входу в систему або загального доступу до адмінпанелі для цього недостатньо.

Доступний для попереднього перегляду - не означає доступний за публічним посиланням

PHP - Previewable does not mean public URL
return [    'id' => $tip->id,    'slug' => $tip->slug,     'url' => $tip->isPubliclyVisible()        ? $tip->showRoute()        : null,     'title' =>        $tip->translation('title'),     'publishedAt' =>        $tip->published_at            ?->toDateTimeString(),     'previewState' =>

Матеріал, доступний для попереднього перегляду, може відкриватися через маршрут детальної сторінки, але ресурс не подає його як звичайну публічну сторінку. Для Draft і Scheduled поле url залишається null.

Інтерфейс бачить, що матеріал доступний для попереднього перегляду, але не поводиться так, ніби для нього вже існує звичайне публічне посилання.

404 - це частина правил публікації

PHP - Anonymous public access
$this->get(    route(        'tips.show',        $published->slug,    ))->assertOk(); $this->get(    route(        'tips.show',        $draft->slug,    ))->assertNotFound(); $this->get(    route(

Наявність маршруту ще не означає, що матеріал існує для публічного доступу. Для звичайного відвідувача неопублікований контент має поводитися так, ніби його немає.

Це також не дозволяє зовнішньому клієнту за окремою відповіддю визначити, що неопублікований slug насправді існує.

Доступ до адмінпанелі ще не означає права на попередній перегляд

PHP - Preview requires its own permission
$this->actingAsAdmin([    PermissionEnum::ADMIN_ACCESS,]); $this->get(    route(        'tips.show',        $draft->slug,    ))->assertNotFound(); $this->get(    route(        'tips.show',        $scheduled->slug,    )

Попередній перегляд не повинен робити матеріал доступним для SEO

PHP - Authorized preview stays non-indexable
$this->get(    route(        'tips.show',        $draft->slug,    ))    ->assertOk()    ->assertInertia(        fn (Assert $page) =>            $page                ->where(                    'tip.data.previewState',                    'draft',                )                ->where(                    'tip.data.url',

Відповідь HTTP 200 під час дозволеного попереднього перегляду ще не робить сторінку публічною. Якщо така сторінка отримає звичайний URL і директиву index, follow, пошуковий робот може сприйняти її як повноцінно опублікований матеріал.

Тому тест одночасно перевіряє три умови: сторінка перебуває в режимі попереднього перегляду, публічний URL відсутній, а значення robots дорівнює noindex, nofollow.

Побічні ефекти теж мають спиратися на єдине правило видимості

PHP - Preview access does not create a public view
$tip = $this->tipService->getTip(    $tip,    $canPreview,); if (    $tip->isPubliclyVisible()) {    $this->publicContentViewTracker        ->track(            $request,            AnalyticsSubject::entity(                ContentSubjectTypeEnum::TIP                    ->definition(),                 AnalyticsEntityIdentity::fromString(

canBeViewed() може повертати true для попереднього перегляду, але аналітика не повинна зараховувати такий запит як звичайний публічний перегляд.

Після перевірки доступу код ставить суворіше запитання: isPubliclyVisible(). Матеріал можна показати в режимі попереднього перегляду, але це не повинно впливати на публічні показники аналітики.

Де Draft найчастіше може просочитися

Шар

Небезпечне спрощення

Detail route

Перевірити лише результат прив’язки моделі за slug.

Scheduled publishing

Вважати status = published достатньою умовою й не перевіряти, чи published_at не вказує на майбутній час.

Preview

Дозволити будь-якому користувачу адмінпанелі переглядати чернетки без окремого права на попередній перегляд.

Resource / UI

Повертати звичайний публічний URL навіть для матеріалу, доступного лише в режимі попереднього перегляду.

SEO

Залишити index, follow для сторінки попереднього перегляду.

Analytics

Зараховувати будь-яку відповідь HTTP 200 як публічний перегляд.

Search / Sitemap / Related

Використовувати окремі локальні перевірки видимості замість єдиного правила публічної доступності.

Тести мають перевіряти межі правил, а не лише один успішний сценарій.

Одного тесту «Published відкривається» недостатньо. Він нічого не говорить про чернетки, заплановані публікації, видалений контент, неправильну локаль або межі прав доступу.

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

Практичний список перевірок для видимості публікацій

  • Одне доменне правило визначає, чи є матеріал публічним саме зараз.

  • Редакційний статус не замінює перевірку часу публікації.

  • Попередній перегляд є окремим рішенням про доступ, а не послабленням правил публічної видимості.

  • Право на попередній перегляд перевіряється на межі HTTP-запиту.

  • Матеріал, доступний лише для попереднього перегляду, не отримує звичайного публічного URL.

  • Для попереднього перегляду встановлюється noindex, nofollow.

  • Попередній перегляд не зараховується як публічний перегляд в аналітиці.

  • Списки матеріалів, пошук, пов’язаний контент і sitemap використовують те саме правило публічної видимості.

  • Регресійні тести охоплюють чернетки, заплановані й опубліковані матеріали, видалений контент, локалі та права доступу.

Висновок

Видимість публікації здається дрібною умовою, доки контент має лише два стани. Але щойно з’являються чернетки, відкладена публікація, попередній перегляд, локалізація, SEO та аналітика, це вже стає системним правилом.

У ICanUp я розділяю три окремі питання: чи є матеріал публічним, чи може конкретний користувач побачити його до публікації і які публічні побічні дії для нього дозволені.

Чернетка не повинна ставати публічною лише тому, що одна з багатьох точок входу перевірила не всі умови. Єдине правило видимості прибирає саме цей клас помилок.