Guests: owning data without an account
How to let a visitor with no account own data — a cart, a comparison list, a wishlist, a draft form — without handing it to the next visitor afterwards.
The trick is simple: a guest has no account, so their rows are signed with a random token and the token is kept in a cookie. The framework holds the cookie, the token format and the lifetime; what the guest actually owns stays in your code.
Issuing a token
use Boshnik\PageBlocks\Support\GuestToken;
$guest = GuestToken::for('shop_cart'); // 60 days by default
$guest = GuestToken::for('shop_wish', 180); // lifetime as the second argument
$token = $guest->get(); // null — no cookie, cleared, or not ours
$token = $guest->getOrIssue(); // issue if absent: the guest adds a first item
$guest->forget(); // the guest stops being a guestA token is 64 hex characters — 32 random bytes and nothing else: no timestamp, no guessable part. The cookie is set with path=/, httponly, samesite=Lax, and secure over HTTPS.
In a controller, via the trait
use Boshnik\PageBlocks\Http\Controllers\Traits\ResolvesGuest;
class WishController
{
use ResolvesGuest;
public function toggle(Request $request): Response
{
// null — this must be written under the account, not under a token.
$token = $this->guestWriteToken('shop_wish', 180);
return $this->items()->toggle($token, $this->currentUserId(), $id);
}
}The trait gives you currentUserId(), isGuest(), guest() and guestWriteToken(). The last one carries the two rules that make this a trait rather than a pair of static calls:
- A token is issued on a write, never on a read.
getOrIssue()on a catalog page would give a cookie to everyone who walked past — and split the response cache across the whole site. So:get()when reading,getOrIssue()on an action. - A signed-in visitor needs no token at all. Their data hangs off the account, and a token here would survive only to show them somebody else's rows from the same browser one day. That is why
guestWriteToken()returnsnullwhen authenticated.
"Authenticated" is not $modx->user->id
Visitor::userId() asks isAuthenticated($context). The id can be 1 (the administrator) even in a web context where nobody is signed in; Visitor::isManager() answers a separate question — whether an admin is viewing the site from the same tab.
A cookie name has to be registered
From your own component's bootstrap.php, next to its routes and model namespace:
use Boshnik\PageBlocks\Support\GuestCookies;
GuestCookies::register('pbShop', ['shop_cart', 'shop_compare', 'shop_wish']);That is how the response cache learns what counts as guest state and tells a personal page from a public one, without knowing that carts exist at all.
Bootstrap, not an event
_loadExtensionPackages() runs inside modX::initialize() — before OnMODXInit, and so before routing, while the cache check is the first thing Route::dispatch() does. A component registering later would be too late: the cache would hand over somebody else's cart in exactly the request it was supposed to protect.
GuestToken::for() on an unregistered name writes an error to the MODX log, once per name per process. Staying quiet is not an option here: an unregistered cookie means the cache cannot tell one guest from another.
What stays in your code
| In the framework | Yours |
|---|---|
| the cookie name, its lifetime and flags | which cookies your component has and how long they live |
| the token format and its issuing | the token column on the row, unique indexes |
| "guest or signed in" | merging guest rows into the account on login |
| the guest cookie registry and the cache fingerprint | cleaning up what was abandoned |
Merging on login is yours. The framework does not know whether an owner has one row or many, and moving rows between owners blindly costs more than writing it yourself.
Why one cache entry per guest is not a problem
It looks like a way to bury the cache partition. It does not, for the same reason the cache is usable at all: saving any PageBlocks model — including a cart row — flushes the whole partition. The guest who added an item has already emptied the cache for everyone.