0% прочитано

Laravel Publication Visibility: How a Draft Can Accidentally Become Public

Draft, Scheduled, and Published look simple until the public route, preview permission, SEO, analytics, search, and sitemap each start deciding visibility differently. Using the real ICanUp Tip contract, I show how one canonical publication policy keeps preview access separate from actual publication.

26 вересня 2026 р. 11 хв читанняLaravel

In ICanUp, publication visibility stopped being a simple status === published check as soon as scheduled publishing, editor preview, localization, SEO, public feeds, and Analytics entered the picture.

The failure can be quiet: one route checks status, another piece of code checks only the date, and a feed or SEO layer uses a third rule. The same entity can remain Draft in Admin while already leaking into the public surface.

For Tips, I kept one domain policy behind public access, preview, the resource URL, SEO, and public side effects.

Scheduled is not a separate status here. It is Published with a future published_at. A status-only check would expose scheduled content too early.

Visibility Is a Domain Contract, Not a Controller Condition

A Tip has one answer to the question: is this entity actually public right now? The contract includes soft deletion, status, publication time, Topic existence, and the title for the current locale.

This matters because visibility is not owned by one controller. Listings, Explorer, Topic pages, search, related content, sitemap, detail pages, and Analytics all need the same answer.

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,            )        );}

Four States, but Not Four Status Values

State

Contract

Draft

status = draft. Not public. Preview requires a dedicated permission.

Scheduled

status = published, but published_at is in the future. Not public, but preview can be allowed.

Published

Publication time has arrived, the record is not deleted, Topic exists, and the localized title is present. Public.

Unavailable

Deleted, wrong locale, missing Topic, or another incomplete public condition. Neither public nor previewable.

Public visibility answers "can a normal visitor see this now?", while preview answers "can an authorized editor inspect content that is not public yet?". Those are different decisions.

Preview Does Not Change Publication State

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() is intentionally broader than isPubliclyVisible(). Access is allowed because content is already public or because the user may preview a supported non-public state.

The permission does not rewrite domain state. Draft does not become Published, Scheduled does not publish early, and a deleted Tip receives no preview state.

Preview is permission to inspect non-public content, not an exception that makes it public.

Permission Is Checked at the Entry Point, the Domain Decides Visibility

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,        ); 

The controller does not redefine Draft or Scheduled. It only obtains one authorization fact: whether the user has TIPS_PREVIEW.

The public service passes that fact into the domain contract. If canBeViewed() returns false, the result is 404. Authentication or broad Admin access alone is not enough.

Previewable Does Not Mean Linkable

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' =>

An authorized preview can render through the detail route, while the resource still refuses to advertise it as a public destination. For Draft and Scheduled content, url remains null.

The UI can expose the preview state without pretending the page already owns a normal public link.

404 Is Part of the Publication Contract

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

Route existence is not public existence. For a normal visitor, non-public content behaves as missing.

This also avoids revealing unpublished slugs through a special access response.

Admin Access Does Not Automatically Mean Preview Permission

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,    )

Preview Must Not Become SEO-Public

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',

An HTTP 200 for an authorized preview does not make the page public. If it also receives a normal URL and index, follow, a crawler can treat the preview as published content.

The test therefore locks three facts together: preview state exists, the public URL is absent, and robots is noindex, nofollow.

Side Effects Must Ask the Same Canonical Visibility Question

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() can be true for preview, but Analytics must not treat that request as a public view.

After the access check, the code asks the stricter question again: isPubliclyVisible(). Preview can render without mutating public metrics.

Where a Draft Can Most Easily Leak

Layer

Dangerous shortcut

Detail route

Only check that route model binding found the slug.

Scheduled publishing

Treat status = published as sufficient and ignore future published_at.

Preview

Allow every Admin user to see Draft content without a dedicated preview permission.

Resource / UI

Return a normal public URL for preview state.

SEO

Keep index, follow for preview.

Analytics

Track every HTTP 200 as a public view.

Search / Sitemap / Related

Maintain local visibility predicates instead of a shared public contract.

Tests Should Lock the Boundary, Not One Happy Path

One "Published opens" test is not enough. It says nothing about Draft, Scheduled, deleted content, wrong locale, or the permission boundary.

A strong regression test checks the matrix: visitor, Admin without preview permission, Admin with preview permission, public SEO, and preview SEO.

A Practical Publication Visibility Checklist

  • One domain function defines the real public state.
  • Editorial status does not replace publication time.
  • Preview is a separate decision, not a weaker public predicate.
  • Preview permission is checked at the request boundary.
  • Non-public preview does not receive a normal public URL.
  • Preview receives noindex, nofollow.
  • Preview is not tracked as a public Analytics view.
  • Listings, search, related content, and sitemap use the same public contract.
  • Regression tests cover Draft, Scheduled, Published, deleted, locale, and permissions.

Conclusion

Publication visibility looks small while content has only two states. With Draft, scheduled publication, preview, localization, SEO, and Analytics, it becomes a system-wide contract.

In ICanUp, I separate three questions: is the content public, may this user preview it, and which public side effects are allowed.

A Draft should not become public because one of many entry points forgot half of the rules. A canonical visibility contract removes exactly that class of failure.