WebProCMS rows and individual elements can be hidden or shown based on rules — like "only on Spanish pages," "only when the visitor arrived from a UTM campaign," "only during a date window," "only on one specific record's page (e.g. a single service or location)," "only for logged-in visitors," or "only for managers and admins." Rules are configured in the page editor; the rendered page evaluates them automatically.
What you can do with it
Every section (row) and every individual element with classes (a wrapper, button, image, etc.) has a Dynamic Visibility panel in the editor's Advanced tab. Authoring a rule is one or two dropdowns:
- Polarity — pick one of:
- Always show (the default; this row always renders on the live site)
- Always hide (the row is removed from the live site; useful when you want to retire a row without deleting it)
- Show when… (the row renders only when a condition matches)
- Hide when… (the row renders unless a condition matches)
- Condition — only appears for "Show when…" / "Hide when…". Pick from: URL query parameter, locale, date window, specific record (only offered on record-driven show pages), visitor's auth state, visitor's role, or an Advanced JSON expression for combining several conditions.
Per-condition fields appear below — parameter name and value, locale picker, two datetime-local inputs, role select, etc. No JSON to author; the form builds and parses the storage format automatically.
A separate Show in builder toggle sits above the Dynamic Visibility panel. That's editor-only state — when off, the row is hidden in the editor preview iframe but the live site is unaffected. Useful for popovers and drawers you want out of the way while editing.
| Rule type | Use it for |
|---|---|
| URL query parameter | Landing pages that change based on ?preview=1 or ?ref=newsletter. (UTM and ad-network tracking params — utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid — are excluded from the response cache key by default, so rules on those keys won't take effect. Use a non-tracking param like ref, campaign, or preview to gate content based on URL.) |
| Locale matches | Language-specific content blocks ("only show this banner on Spanish pages"). |
| Date window | Promotions that go live at midnight and disappear after a week. Holiday banners. Sale-end CTAs. |
| Specific record | On a record-driven show page (/services/{id}, /locations/{location}, a blog post, an event…), gate a row to a single record — e.g. an extra testimonial or FAQ that appears only on the "Premium Detailing" service page while the rest of the template stays uniform across every record. The condition is only offered when the page is record-driven (it reuses the same record list as the editor's Preview as dropdown); on a static page it doesn't appear. |
| Visitor logged in / logged out | Different headers for signed-in users. CTAs that disappear after sign-up. |
| Visitor has role (at least) | Manager-only buttons. Admin-only nav items. Internal-tools links. |
| All of / Any of (composite) | "Show only for logged-in managers viewing in Spanish during the launch week." Mix any rules above. Only accessible via the Advanced (combined / JSON) option in v1 — a visual builder is on the roadmap. |
Leaving the rule type set to Always show means the element always renders — same as today's behavior.
Uniform template, one custom row
A record-driven show page (services, locations, blog posts, events) renders every record through one shared template, so adding a row to it normally adds that row to every record. The Specific record condition is the escape hatch: put the extra row on the shared template, set its visibility to "Show when… specific record → Premium Detailing," and it renders on that one record's page only. The rule lives once on the template but is evaluated against each request's record, so one page shows the row and the rest don't — no separate bespoke page, no duplicated template. Because each record has its own URL, each version is cached independently (see the architecture section below).
Show when vs. Hide when
Polarity is the first dropdown in the panel because that's how authors actually think about the rule. The same condition reads two different intents depending on which one you pick:
| Polarity | Condition | Result |
|---|---|---|
| Show when… | URL query parameter preview=1 |
Row appears only on ?preview=1 URLs |
| Hide when… | URL query parameter preview=1 |
Row appears on every URL except ?preview=1 |
| Show when… | Visitor is logged in | Visible only to signed-in visitors |
| Hide when… | Visitor is logged in | Visible only to signed-out visitors (the inverse) |
| Show when… | Locale matches es |
Renders only on Spanish pages |
| Hide when… | Locale matches es |
Renders on every locale except Spanish |
| Show when… | Date is within window 2026-06-01 – 06-30 | Visible only during that window |
| Hide when… | Date is within window 2026-06-01 – 06-30 | Visible before and after that window, never during |
| Show when… | Specific record premium-detailing |
Row appears only on that record's page |
| Hide when… | Specific record premium-detailing |
Row appears on every record's page except that one |
Internally "Hide when X" is stored as "X with not: true" — the resolver evaluates X, then flips the result. You don't see that detail in the editor.
Rule shapes on disk (JSON)
You shouldn't need to read or write these directly — the editor form generates them. They're documented here for site admins debugging via the database or shipping rules via migration:
// URL query param ("not": true flips: hide when ?preview=1, show otherwise)
{"type":"query_param","key":"preview","value":"1"}
{"type":"query_param","key":"preview","value":"1","not":true}
// Locale match (also supports "not")
{"type":"locale","value":"es"}
// Date window — site timezone (Dashboard → Settings → Business → Timezone)
{"type":"date_window","start":"2026-06-01T00:00","end":"2026-06-30T23:59"}
// Specific record — matches the show page's route parameter (id or slug).
// "value" is the record's route value, the same value the "Preview as"
// dropdown uses. "not": true inverts (show on every record except this one).
{"type":"record","value":"premium-detailing"}
{"type":"record","value":"premium-detailing","not":true}
// Visitor auth state
{"type":"auth","value":"in"} // logged in
{"type":"auth","value":"out"} // logged out
// At-least role match (standard < manager < admin < super)
{"type":"role","value":"manager"}
// Composite — every child must pass (negate the whole thing with "not": true)
{"type":"all","rules":[
{"type":"locale","value":"es"},
{"type":"date_window","start":"2026-06-01T00:00","end":"2026-06-30T23:59"}
]}
// Composite — at least one child must pass
{"type":"any","rules":[
{"type":"role","value":"manager"},
{"type":"query_param","key":"preview","value":"1"}
]}
Bad JSON or an unknown rule type fails open — the element renders normally. A typo never permanently hides content.
Editor behavior
The editor preview iframe always shows every row and element, ignoring the rule. The rule is for the live site only. This is intentional — the editor's job is to edit, not to simulate every visitor's experience. You see what you can edit; the rule fires only when a real visitor lands on the page.
If you need to verify a rule actually hides something, open the live site in a new tab (or an Incognito window) and visit the URL.
How it works under the hood (the architecture story)
WebProCMS caches every public page once and serves it to every visitor — that's how the public site stays fast at scale. Conditional rendering is built to preserve that property:
| Rule depends on… | Where it's evaluated | What the cache stores |
|---|---|---|
| URL (query param, locale, date window, specific record) | Server-side, at render time. | The resolved output — either the element, or nothing. Identical for every visitor. |
| Visitor (auth, role) | A tiny JS shim, after page load. | The element rendered hidden with a data-visibility attribute. Identical for every visitor; the shim reveals or keeps hidden based on the visitor's own state. |
In other words:
- Locale-based or date-based rules don't even reach the browser when they don't match — the server omits them and the cached HTML is empty for that slot.
- Specific-record rules are the cleanest cacheable case: the matched record is part of the URL path (
/services/premium-detailingis a different URL from/services/basic-wash), so each record's page is already its own cache entry. The server resolves show/hide per-URL and stores the right output for each — no client JS, no shim involved. - Auth- or role-based rules survive in the cache as inert markup. A ~1KB JS file (
visibility.js) on every public page reads a cookie that tracks whether the visitor is logged in (and at what role), then reveals or hides matching elements within milliseconds of page load.
The cached HTML never varies per visitor. Per-visitor variance happens entirely in the browser via the cookie and shim.
The visitor cookie
When a user logs in, WebProCMS writes a small cookie called wpcms_visitor containing two fields:
{"auth": true, "role": "manager"}
The visibility shim reads this cookie (no server round-trip) to decide which rules pass. The cookie:
- Is not HttpOnly — the JS shim has to read it. It contains no sensitive data; the visitor already knows their own auth state.
- Is cleared on logout.
- Updates automatically when an admin changes a user's role and that user makes their next request.
- Has a 30-day lifetime.
No cookie is written for anonymous visitors (a missing cookie is treated as anonymous).
Composite rules with mixed cacheability
When a composite (all / any) contains both a URL-based rule and a visitor-based rule, the server evaluates the URL rule and — if it can decide the whole composite from that alone — emits the final outcome. If a visitor rule is still needed to decide, the server emits the element hidden with the remaining rule attached, and the shim finishes the evaluation client-side. The composite's cacheability propagates correctly.
A note on tracking parameters and the response cache
WebProCMS strips common analytics tracking parameters (utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid) from the response-cache key by default — without that, every UTM permutation would multiply the cache footprint. The side effect: a URL query parameter rule whose parameter name is one of those tracking keys won't take effect on the live site, because all visitors hitting the URL get the same cached body regardless of the UTM value. Use a non-tracking key like preview, ref, or campaign for visibility rules; the editor's helper text reminds you of this on the URL query parameter form.
What's not in the box yet
Tracked but not v1:
- Visual builder for combined rules. The
all of/any ofcomposites are supported by the renderer today, but the editor surfaces them only through the Advanced (combined / JSON) option (raw JSON textarea). A visual "add condition" builder is on the roadmap. - Recurring weekly date windows ("every Tuesday from 9 to noon"). Today's date_window is a single start/end pair.
- UTM / referrer segments that persist across sessions. You can use
query_paramfor the first-touch URL, but there's no built-in "the visitor arrived from Google three days ago" memory. - Editor preview "simulation" modes ("preview as logged-out admin," "preview at 2026-12-25"). Today the editor always shows everything; verify live behavior in a separate tab.
- Per-item visibility inside repeaters / galleries. Those items come from a data source (events, posts, locations) and are generated at render time — gating them is the data source's job (its filters), not the visibility resolver's. A hand-built grid item is a different thing and does carry its own rule (see below).
- Visibility on nav menu items. Menu items use a different data model; tracked separately.
For developers
The resolver lives in app/Support/VisibilityResolver.php and is invoked from resources/views/components/dl/section.blade.php, resources/views/components/dl/wrapper.blade.php, and resources/views/components/dl/grid-item.blade.php — the three components whose schema registers a visibility field. A gated grid item suppresses its own cell rather than rendering empty; nesting a gated wrapper inside the item is not equivalent, because the cell survives and leaves a hole in the track. The JS shim is at resources/js/visibility.js. The cookie writer is the SyncVisitorCookie middleware, appended to the web middleware group.
To add a new rule type:
- Add the
caseinVisibilityResolver::evaluate(). - If the rule depends only on the URL/locale/clock (including the route path — that's how
recordworks, matching$request->route()->parameters()), mark it cacheable inVisibilityRule::CACHEABLE_TYPESso composites resolve correctly. URL-path-derived rules need no JS shim — each path is already its own cache entry. - If the rule depends on visitor state, also add the case in the JS shim (
evalRuleinvisibility.js). - Add the dropdown option + per-condition fields in the page-editor partial (advanced-tab-groups.blade.php), and the matching parse/build cases in the
visibilityRuleEditorAlpine component in resources/js/manager.js. - Cover the new case in tests/Feature/VisibilityResolverTest.php and tests/Feature/VisibilityRulesTest.php.
The record condition is the worked example of a URL-path rule. Its editor picker reuses the page's @previewContext record list (the same data behind the "Preview as" dropdown): the list is seeded into the editor store at boot as previewRecords and refreshed on in-place file switches via the editor-preview-records event, so the "Specific record" option only surfaces on record-driven show pages.
Architecture detail and scope notes live in docs/conditional-logic.md (internal).