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.
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.
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 |
|
Scheduled |
|
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. |
Preview Does Not Change Publication State
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
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
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
$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
$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
$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
$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 |
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 |
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.



