goldnead / statamic-accounts
Accounts for Statamic: email verification, email change with confirmation, account deletion with a grace period, a personal data export every addon contributes to, and a customer overview in the Control Panel.
Package info
github.com/goldnead/statamic-accounts
Type:statamic-addon
pkg:composer/goldnead/statamic-accounts
Requires
- php: ^8.2
- laravel/framework: ^12.40|^13.0
- statamic/cms: ^6.0
Requires (Dev)
- goldnead/statamic-brand-context: ^1.13
- goldnead/statamic-identity-contracts: ^1.2
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
- ext-zip: Delivers the personal data export as a ZIP with one JSON file per addon. Without it the export is a single JSON file.
- goldnead/statamic-activity: Records verifications, email changes, deletions, exports and impersonations in the activity ledger.
- goldnead/statamic-automations: Adds every account event as an automation trigger.
- goldnead/statamic-brand-context: Makes the grace period, link lifetimes and mail templates editable per brand under Settings.
- goldnead/statamic-email-templates: Lets editors write every account mail in the Control Panel.
- goldnead/statamic-entitlements: Access grants in the customer overview and the data export.
- goldnead/statamic-identity-contracts: Attributes everything done while impersonating to the customer, with the admin named alongside.
- goldnead/statamic-leadhub: The CRM contact in the customer overview and the data export.
- goldnead/statamic-notifications: Notifications and preferences in the data export.
- goldnead/statamic-payments: Payments and subscriptions in the customer overview and the data export.
- goldnead/statamic-teams: Team memberships in the customer overview and the data export.
- goldnead/statamic-webhook-manager: Adds every account event as a webhook trigger.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-25 20:10:19 UTC
README
Statamic Accounts
The account features Statamic leaves to you: email verification, changing the address, deleting the account with a grace period, a personal data export, and a customer overview in the Control Panel.
Login, registration, the profile and password forms, password reset, two-factor authentication, passkeys, OAuth, elevated sessions and impersonation are Statamic's own. This addon does not replace any of them. It adds what core does not have:
- Email verification for frontend users: a signed link, a "send again" form, and
the
accounts.verifiedmiddleware that holds unconfirmed users back. - Changing the address with confirmation: the new address is active only after its link is opened; the old one is told.
- Deleting the account with a grace period (14 days by default). The customer can
withdraw until then; a daily run deletes the account and what every addon holds
about the person (
ErasesPersonalData), keeps what the law says must be kept (payments, invoices), and records which was which. - Personal data export (GDPR Art. 15/20): a ZIP with one JSON file per addon.
Every addon contributes its share through
ContributesPersonalData. - Customer overview in the Control Panel: payments, subscriptions, access grants, teams and history of one person on one screen, with "Sign in as" (core's impersonation, recorded).
- Wiring: a Control Panel screen that shows every event, its mail template and how many automations and webhooks listen to it.
Works with Statamic's file users and with Eloquent users (integer or uuid ids).
Requirements
| Statamic | 6.0+ |
| PHP | 8.2+ |
| Laravel | 12.40+ or 13 |
| Database | yes, one table (account_requests) |
Installation
composer require goldnead/statamic-accounts
php artisan migrate
The purge is scheduled daily at 03:40 through Statamic's addon scheduler; make sure
php artisan schedule:run runs on the server.
To edit the mails in the Control Panel, install goldnead/statamic-email-templates
and run php please email-templates:import: the five default mails become entries.
Frontend
Tags
Shaped like Statamic's user:* tags. Form tags render <form> with CSRF around their
contents and hand them success, errors (list) and error (by field). redirect
sets where to go after submitting (a path on the site).
{{# Only renders for a signed-in user whose address is not confirmed. #}} {{ accounts:verify_notice }} {{ if success }}<p>{{ success }}</p>{{ /if }} <p>Please confirm {{ email }}.</p> <button>Send the link again</button> {{ /accounts:verify_notice }} {{ accounts:change_email_form redirect="/account" }} {{ if pending_email }}<p>Waiting for {{ pending_email }} until {{ pending_expires }}.</p>{{ /if }} <input type="email" name="email"> {{ error:email }} <button {{ if locked }}disabled{{ /if }}>Change address</button> {{ /accounts:change_email_form }} {{ accounts:delete_form }} {{ if pending }} <p>Your account will be deleted on {{ scheduled_for }}.</p> <button>Keep my account</button> {{ elseif blockers }} <ul>{{ blockers }}<li>{{ value }}</li>{{ /blockers }}</ul> {{ else }} {{ error:account }} <button>Delete my account in {{ grace_days }} days</button> {{ /if }} {{ /accounts:delete_form }} <a href="{{ accounts:export_url }}">Download my data</a> {{# The outcome of a link from a mail: confirmed, expired, withdrawn. #}} {{ accounts:status }}<p class="{{ kind }}">{{ message }}</p>{{ /accounts:status }} {{ accounts:impersonating }} <p>{{ impersonator }} is signed in as you. <a href="{{ stop_url }}">Stop</a></p> {{ /accounts:impersonating }} {{ if {accounts:verified} }}…{{ /if }}
| Tag | Variables |
|---|---|
accounts:verify_notice |
email, success, errors, error |
accounts:change_email_form |
email, pending_email, pending_expires, elevated, locked, cancel_url, success, errors, error, old |
accounts:delete_form |
pending, scheduled_for, grace_days, blockers, elevated, locked, success, errors, error |
accounts:export_url |
the URL |
accounts:status |
kind (success/error), message |
accounts:impersonating |
stop_url, impersonator |
accounts:verified |
bool |
Confirmation is Statamic's elevated session. Changing the address, deleting and
downloading the data need one (statamic.users.elevated_sessions_enabled). Without it
the visitor is sent to core's confirmation page (password, passkey or mailed code,
whatever the account has) and comes back to where they were; the download starts on
its own. elevated tells a template whether the session already is. With elevated
sessions switched off in Statamic there is no second confirmation, as for core's own
sensitive actions.
While an admin is signed in as the customer (locked), all three answer 403: they
are the person's own decisions. The services refuse too (AccountException with
field = impersonation), so an API layer cannot skip it.
Middleware
Route::middleware(['web', 'auth', 'accounts.verified'])->group(…);
A signed-in user without a confirmed address goes to accounts.verification.notice_url;
a JSON request gets 403. Guests pass (that is what auth is for).
Routes
All under /!/statamic-accounts/, named statamic.accounts.*. The GET links from the
mails are Laravel temporary signed URLs: a changed parameter or an expired link is
refused with 403, before any code of this addon runs.
Events
Every change of state is a Laravel event with a serialisable payload: ids and addresses, never a link or a token.
| Event | Trigger handle | Payload beyond user_id, email, name |
|
|---|---|---|---|
EmailVerificationSent |
accounts.verification.sent |
accounts-verify-email |
|
EmailVerified |
accounts.email.verified |
||
EmailChangeRequested |
accounts.email_change.requested |
new_email |
accounts-confirm-email-change |
EmailChanged |
accounts.email.changed |
old_email (email is the new one) |
accounts-email-changed (to the old address) |
AccountDeletionRequested |
accounts.deletion.requested |
scheduled_for |
accounts-deletion-scheduled |
AccountDeletionCancelled |
accounts.deletion.cancelled |
||
AccountDeletionBlocked |
accounts.deletion.blocked |
reasons (how many) |
accounts-deletion-blocked |
AccountDeleting |
(hook, no trigger) | ||
AccountDeleted |
accounts.deleted |
accounts-account-deleted |
|
PersonalDataExported |
accounts.data.exported |
sections, requested_by |
All in Goldnead\Accounts\Events. AccountDeleting is dispatched synchronously while
the user still exists, for addons that must clean up (cancel a subscription, anonymise
a contact). A listener that throws keeps that one account scheduled; the next run
tries again.
With goldnead/statamic-automations installed, every event is an automation trigger
(group "Accounts", context under account.*). With goldnead/statamic-webhook-manager,
every event is a webhook trigger. Both register through the siblings' own
registerEventTrigger().
One confirmation mail, not two
email-templates can also send Laravel's own VerifyEmail (template core-verify-email).
To keep a site from sending both, accounts.verification.mail picks one line:
auto(default): an Eloquent user model that implements Laravel'sMustVerifyEmail, on a site with Laravel'sverification.verifyroute, gets Laravel's notification ($model->sendEmailVerificationNotification()); its link goes to the site's own route, and Laravel'sVerifiedevent is turned into this addon'sEmailVerifiedwith the same ledger entry. Everyone else, and every Statamic file user (they never implementMustVerifyEmail), gets this addon's mailaccounts-verify-email.accounts/laravel: force one.
Statamic's own registration form does not fire Laravel's Registered event, so on a
Statamic-only site nothing else sends a confirmation. A site that fires Registered
itself (ChoirLive's API does) and also uses auto sends exactly one: Laravel's.
Mails
Each mail is a template slug in goldnead/statamic-email-templates
(accounts.mail.templates.*), announced to its registry (email-templates.registry)
with the occasion ("Accounts: …"), the event, every placeholder with an example, and
the shipped text, so the template screen explains itself and email-templates:import
writes the defaults. A slug without an entry, or a site without that addon,
sends the default text shipped in lang/{de,en}/mail.php. Placeholders:
{{ user.name }}, {{ user.email }}, {{ action_url }}, {{ new_email }},
{{ old_email }}, {{ scheduled_for }}, {{ grace_days }}, {{ expires_in_hours }},
{{ site_name }}, {{ changed_at }}. Mails are sent, not queued.
Two security notices go out without anybody asking for them:
- Change of address: the current address is told when a new one is entered
(
accounts-email-change-requested, before the new one counts) and again once it is confirmed (accounts-email-changed).email_change.notify_old_addressswitches both. - Password changed (
accounts-password-changed, eventaccounts.password.changed): watched at the user's save, so the Statamic profile form, the Control Panel, a reset link and the host's own code all announce it. A new account is not told. Off withpassword_change.notify.
Deleting: what goes, what stays
When a deletion is due, accounts:purge:
- asks every eraser whether anything stands in the way. If so, the request becomes
blocked: nothing changes, the person gets one mail (accounts-deletion-blocked) with the reasons and a withdraw link (30 days),AccountDeletionBlockedfires, and the Control Panel marks the account. The next daily runs try again and delete once the way is clear. A blocked or overdue request can always be withdrawn. - with
deletion.active_subscriptions = cancel, cancels the running subscriptions through payments, now and not earlier: a request that is withdrawn or blocked by something else leaves them running. One that cannot be cancelled blocks. - in one database transaction: runs every eraser, dispatches
AccountDeleting(the user still exists), deletes the user (Eloquent or file, last) and stores the result on the deletion request (row counts only). Anything failing rolls the database back; the account stays scheduled andAccountDeletingcounts as not having happened.
What stays, pseudonymously: the deletion request rows keep the user id (no address, no name) as the record that the deletion happened; this addon's own ledger entries (impersonation, a deletion scheduled or withdrawn by an admin) are written under the admin's id and name the person only by id, never by address. After the deletion that id points at nothing.
| Addon | On deletion |
|---|---|
| accounts | Address changes deleted; deletion requests stay as the record, address and meta cleared |
| activity | activity:anonymize --user=<id> (the ledger's own API): entries under the user id keep type and time, lose user, actor, properties, context. Entries recorded under another user id that mention the person are beyond that API and stay; the record says so. This addon writes ids only into its own entries, so nothing there names the person by address |
| entitlements | Grants held by the user and by the address deleted; team grants stay with the team |
| leadhub | Contact deleted with events, notes, follow-ups, tasks, revenue lines (database driver) |
| notifications | Notifications, preferences, digest runs deleted |
| teams | Memberships removed; a team held alone without other members is deleted with its invitations and roles; an ownership shared with another owner passes to them; the person's id comes off invitations they sent |
| payments | Kept, untouched: accounting records, ten years (§ 147 AO, § 14b UStG) |
| invoices | Kept, untouched: tax documents, ten years |
What blocks a deletion (at the request and again when it is due):
- A subscription that still charges (
pending,active,paused,suspended).deletion.active_subscriptions = block(default) refuses with a link to the customer portal;cancelcancels it through payments' ownSubscriptions::cancel()when the deletion is requested, and blocks only if that fails. - Being the only owner of a team that has other members. Transfer the ownership first.
use Goldnead\Accounts\Contracts\ErasesPersonalData; use Goldnead\Accounts\PersonalData\ErasureResult; class CourseProgressEraser implements ErasesPersonalData { public function key(): string { return 'courses'; } public function label(): string { return 'Courses'; } public function available(): bool { return true; } public function blockers(User $user, string $audience = 'customer'): array { return []; } // 'customer': "you …"; 'admin': third person for the CP public function erase(User $user): ErasureResult { $n = Progress::where('user_id', $user->id())->delete(); return new ErasureResult('courses', deleted: ['progress' => $n]); } } Accounts::eraseData(CourseProgressEraser::class); // or: $this->app->tag([CourseProgressEraser::class], 'accounts.personal-data-erasers');
Personal data export
use Goldnead\Accounts\Contracts\ContributesPersonalData; use Goldnead\Accounts\Facades\Accounts; use Statamic\Auth\User; class CourseProgress implements ContributesPersonalData { public function key(): string { return 'courses'; } // courses.json public function label(): string { return 'Courses'; } public function available(): bool { return true; } public function collect(User $user): array { return ['progress' => …]; } } Accounts::contributeData(CourseProgress::class); // or: $this->app->tag([CourseProgress::class], 'accounts.personal-data');
Shipped contributors, each active only when its addon's tables exist: account,
payments (payments, items, subscriptions, withdrawals, cancellations by address),
entitlements (grants held by the address or the user), leadhub (contact by address
or user id, with events, notes, follow-ups, revenue; database driver only),
notifications (items, preferences, digests), teams (memberships), invoices
(invoices and lines by buyer address), activity (ledger entries under the user id). They read the
siblings' tables directly, across all brands, and leave out password hashes, tokens and
IP hashes. A contributor registered later under the same key replaces the shipped one.
manifest.json in the ZIP names the sections and any contributor that failed. Without
ext-zip the export is one JSON file.
Control Panel
- Accounts → Customers: every account with its confirmation state and a scheduled deletion. Also reachable from core's Users listing (row action "Customer overview").
- Customer overview: account, payments, subscriptions, access grants, teams and the latest activity entries. Actions: send the confirmation link, mark as confirmed, schedule or withdraw deletion, export data, sign in as.
- Accounts → Wiring: events, their mail template (own entry or shipped text), the number of automations and webhooks listening, which sibling addons are installed, and the export contributors.
Permissions
| Permission | Allows |
|---|---|
view accounts |
the list, the overview, the wiring screen |
manage accounts |
send the confirmation link, mark as confirmed, withdraw a deletion |
delete users (core) |
schedule a deletion; a super admin only by a super admin |
export account data |
the export from the Control Panel |
manage accounts settings |
the settings screen (with brand-context) |
impersonate users (core) |
"Sign in as" |
Impersonation
"Sign in as" runs core's Statamic\Actions\Impersonate: the same permission, the same
elevated session, the same "Stop impersonating". This addon adds two things. Every start
and end, from the overview or from core's user listing, goes into
goldnead/statamic-activity (or the application log without it). And while it lasts,
goldnead/statamic-identity-contracts reports the customer as the actor with
meta.impersonated_by set to the admin, so every addon that records an actor shows
who really acted.
Configuration
config/accounts.php, publish with php artisan vendor:publish --tag=accounts-config.
| Key | Default | |
|---|---|---|
verification.enabled |
true |
Off: the middleware lets everyone through, the notice tag renders nothing |
verification.field |
email_verified_at |
Where the moment of confirmation is stored on the user |
verification.send_on_register |
true |
Send after core's registration form |
verification.expire_minutes |
1440 |
Link lifetime |
verification.notice_url |
/ |
Where the middleware sends unconfirmed users |
verification.redirect |
/ |
Where the link lands |
email_change.expire_minutes |
1440 |
|
email_change.notify_old_address |
true |
Tell the current address when a new one is entered, and again once it is confirmed |
password_change.notify |
true |
"Your password was changed" to the account's address, however it was changed |
email_change.redirect |
/ |
|
deletion.grace_days |
14 |
At least 1 |
deletion.active_subscriptions |
block |
block or cancel, see above |
deletion.portal_url |
'' |
Where the subscription blocker links; empty: payments' portal |
deletion.logout |
false |
Sign out after requesting |
deletion.redirect |
/ |
Where the withdraw link lands |
export.enabled |
true |
Off: the customer's export answers 404 (the CP export stays) |
export.throttle |
3,60 |
The customer's download: at most 3 per 60 minutes per person (max,minutes); more answers 429. The CP export is not limited |
mail.templates.* |
accounts-… |
Slugs in email-templates |
impersonation.redirect |
/ |
Landing page for a customer without CP access |
integrations.automations / webhook_manager / activity |
true |
Read while booting |
subject_types |
— | Extra subject_type values under which entitlements stores users |
With goldnead/statamic-brand-context, the grace period, link lifetimes, notice page,
switches and template slugs are editable per brand under Settings.
PHP API
Everything the tags and the Control Panel do goes through services another addon can
call directly (an API layer, a site's own controller). All take a Statamic\Auth\User.
use Goldnead\Accounts\Facades\Accounts; Accounts::verification()->isVerified($user); // bool Accounts::verification()->send($user); // bool, false when already confirmed Accounts::verification()->url($user); // signed link Accounts::verification()->verify($userId, $hash); // ?User; checks the address hash Accounts::verification()->markVerified($user, $by); Accounts::emailChange()->request($user, 'new@example.com'); // AccountRequest; throws AccountException Accounts::emailChange()->pending($user); // ?AccountRequest Accounts::emailChange()->confirm($requestId, $hash); // User; throws AccountException Accounts::emailChange()->cancel($user); // bool Accounts::deletion()->blockers($user); // list<string>; with the cancel policy, cancels first Accounts::deletion()->request($user, $by); // AccountRequest (idempotent while pending); throws AccountException when blocked or impersonating Accounts::deletion()->pending($user); // ?AccountRequest, ->due_at Accounts::deletion()->cancel($user, $by); // bool Accounts::deletion()->graceDays(); // int Accounts::deletion()->purgeDue(); // int, what `accounts:purge` runs Accounts::export()->collect($user); // ['manifest' => …, 'sections' => [key => array]] Accounts::export()->build($user, 'customer'); // ['path', 'filename', 'mime'], caller deletes the file Accounts::overview()->for($user); // the customer overview as arrays Accounts::impersonation()->allowed($admin, $user); Accounts::impersonation()->start($admin, $user); // redirect URL; throws AuthorizationException Accounts::contributeData(MyContributor::class); Accounts::eraseData(MyEraser::class); Accounts::erasure()->erase($user); // array<key, ErasureResult>; normally only via the purge
emailChange()->request(), deletion()->request() and export()->build($user, 'customer')
refuse while an impersonation is active. They do not check the elevated session; that
is the caller's HTTP concern (this addon's controllers do it, an API layer decides for
itself).
Goldnead\Accounts\Exceptions\AccountException carries a customer-facing message and
the form field it belongs to, ready to become a 422.
Commands
php please accounts:purge deletes the accounts whose grace period is over.
License
Proprietary, part of the goldnead suite licence. See LICENSE.md.