Skip to content

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 ​

php
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 guest

A 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 ​

php
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() returns null when 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.

From your own component's bootstrap.php, next to its routes and model namespace:

php
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 frameworkYours
the cookie name, its lifetime and flagswhich cookies your component has and how long they live
the token format and its issuingthe 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 fingerprintcleaning 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.

© PageBlocks 2019-present