components-web-app / components-web-app/api-components-bundle
Feature: Route-level live / scheduled publication date
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 32
- Forks
- 8
- PR merge metrics
- No merged PRs in 30d
Description
Problem
A Route is live the moment it exists. There is no way to say "this URL goes live on Friday", and no way to take a URL offline without deleting the Route — which loses the URL, its redirectedFrom chain, and any children hanging off cascadeChildPaths.
Today the only publication signal for a page is the presence of a Route, which is all-or-nothing and immediate.
Desired behaviour
A per-Route publication state:
- Live — current behaviour, resolvable by anyone.
- Scheduled — a
publishedAt-style datetime. Before that moment the route behaves, for public traffic, exactly as if it did not exist (404). At/after it, it is live with no further action. - (Implied third state: a datetime of
null= not live / taken offline indefinitely, keeping the URL reserved.)
Admins (ROLE_ADMIN, or whatever publishable.permission resolves to) must still resolve and edit a scheduled route so the page can be previewed and built before launch.
The scheduling semantics already exist
PublishableExtension::updateQueryBuilderForUnauthorizedUsers() is already exactly this rule:
->andWhere("$alias.$configuration->fieldName IS NOT NULL")
->andWhere("$alias.$configuration->fieldName <= :currentTime")
So "future publishedAt = not yet visible to the public, becomes visible on its own" is proven machinery — a component with a future publishedAt is already a scheduled component.
But #[Silverback\Publishable] is probably the wrong tool here, and this is the first thing to decide:
- Publishable is a draft/published pair —
publishedResource/draftResource,PublishableEventListenermerging a draft into its published twin,?published=on every write. A Route has no draft twin; there is one Route row and the question is only when it starts resolving. - Adopting the full attribute would drag the whole pairing lifecycle, the
Route:publishableserialization groups and the?published=write-routing onto an entity that needs none of it.
Likely shape instead: a plain nullable publishedAt (name TBD — liveAt/publishedAt) on Route, plus a small query extension + voter check reusing the same IS NOT NULL AND <= :now predicate. Worth reviewing whether the predicate can be factored out of PublishableExtension so there is one implementation of "is it live yet".
Where the gate has to apply
Route resolution is not the only path to a page's content, so a single check in RouteStateProvider is not enough. Every one of these needs deciding:
RouteVoter::voteOnAttribute()/read_route— the itemGET /_/routes/{path}. This is the natural home for the check, and gets most of the rest for free (below).GetCollectionof routes — a not-yet-live route must not appear in the public collection (a Doctrine query extension, since voters do not run per-item on collections — same reasoning as theDenyAccessListenerdocblock).ResourceManifestVoter/ResourceManifestStateProvider—/_/resource_manifest/{path|uuid}is a second door to the same tree.ComponentVoter::voteByRoute()andisRouteReachableResource()— these issue a sub-request to the route IRI and treat 401/403 as "not reachable". A 404 is not caught there —isPathReachable()only swallowsHTTP_UNAUTHORIZED/HTTP_FORBIDDENand rethrows anything else. If a scheduled route 404s, that rethrow turns a component access check into a 500. Decide whether a scheduled route should 403 (cleanest for the voter chain) and be translated to a 404 at the edge, or whetherisPathReachable()should also catch 404.DenyAccessListener::isPageDataAllowedByRoute()— same, viaRouteVoter.RouteChildrenStateProviderand/routes/{id}/redirects— should a public caller see not-yet-live children? (Probably not; admin yes.)cascadeChildPaths— a scheduled parent whose path changes before go-live should still cascade.- Redirects — a Route whose
redirecttarget is not yet live should behave as if the target does not exist, rather than 308ing into a 404.
Caching (the non-obvious one)
CacheHeadersEventListener currently marks authenticated requests to Route as private, no-store and leaves anonymous ones public with API Platform's s-maxage.
A scheduled route breaks that assumption: an anonymous 404 for /launch cached with a long s-maxage will still be a 404 after the go-live moment, because nothing invalidates it — the resource did not change, the clock moved. Options:
- Cap
s-maxageatmin(default, secondsUntilGoLive)for a scheduled route (and for a route that is live, at the next scheduled state change if one exists). - Or purge on schedule, which needs a scheduler and is worse.
The same applies to the manifest response and to any component reachable only via that route. This needs solving as part of the feature, not after — a cached pre-launch 404 is the exact failure mode the feature is meant to avoid.
Relationship to #186
#186 (#[Publishable] on AbstractPage / AbstractPageData) is the page-level draft/live toggle and is deliberately deferred — the blockers recorded there are component permission inheritance, undecided front-end draft/live UX, and the hero-component state conflict (a page and a hero component each having their own publishedAt producing incoherent states).
Those blockers mostly do not apply to a Route-level gate, which is why this is worth doing separately rather than waiting on #186:
- It gates URL resolution, not resource draft state, so there is no second
publishedAton the page or its components to disagree with. - No hero-component conflict: the page's content publishing story is unchanged.
- The front-end UX question is much smaller — one flag on the route admin, not a whole draft-preview scheme.
What it does not give you is #186's other half: taking a page offline while keeping the entity in its current state is possible here (clear the date), but "this page is a draft even though its URL is live" is not.
Decision to record before implementing: whether this supersedes #186, complements it, or should be implemented as the same mechanism applied at both levels.
Acceptance criteria
-
Routegains a nullable publication datetime, exposed on the admin-readable groups and writable byROLE_ADMIN - Anonymous
GET /_/routes/{path}for a route with a future/absent date does not resolve the page (403/404 — decision above) - Anonymous
GET /_/resource_manifest/{path}for the same route likewise - The route does not appear in the anonymous route collection, the children endpoint, or the sitemap source
- Admin access, editing and preview of a scheduled route is unaffected
- Components reachable only via a not-yet-live route are not publicly readable, and the check does not 500 (
isPathReachable()behaviour above) - Cache headers cannot outlive the go-live moment
- Existing routes migrate to "live" (backwards compatible)
- Behat: public denied before the date, public allowed after, admin allowed throughout, collection/children/redirect behaviour
Front-end counterpart: components-web-app/cwa-nuxt-module#287
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reviewing RouteVoter, the route collection query extension, ResourceManifestVoter/ResourceManifestStateProvider, ComponentVoter, RouteChildrenStateProvider, and CacheHeadersEventListener. Trace how anonymous and admin requests reach these entry points, then resolve the stated 403/404 and caching decisions. Done means the acceptance criteria pass, including Behat coverage for scheduling, collections, children, redirects, manifests, components, caching, and migration.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php
- Domain
- api, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100