XAuth mirror
A mirror is a copy of some console tables inside your database, so lists and joins stay local. This page is the contract it must keep, and the commands that prove and repair it.
The contract
The console database is the source of truth. A mirror table holds exactly the rows the console grants this app, with exactly the console's values, in exactly the columns you declared. No more, no less.
| Word | Meaning | Enforced by |
|---|---|---|
| rows | the app's tenant scope — never wider, never narrower | the engine's scope rules |
| values | every field equals the console's, not only the ones with a fresh timestamp | the content hash |
| columns | the columns you declared, narrowed by the project's grant — nothing else exists in the table | schema check, drift |
| exactly | a difference is detected, reported and repaired, never tolerated | verify, self-repair |
A mirror is for display and joins. It is never where a permission is decided — that goes to AuthGate.
What verify proves
Each table is compared with the console through four independent fingerprints:
| Fingerprint | Catches | Cannot see |
|---|---|---|
| row count | inserts, deletes | edits |
| id checksum | a delete paired with an insert | edits |
newest updated_at | edits that bump the stamp | edits that don't |
| content hash | any value difference, on either side | nothing in the mirrored columns |
The first three are not enough. A writer that changes a row without touching
updated_at — a last-login stamp is the live example — changes none of
them: the differential pull never fetches that row and the digest says "in sync".
The same blindness hides a row someone edited locally. The content hash is an
order-independent hash of every mirrored column of every row, computed with the same
SQL on both sides.
Self-repair
The differential pull is an optimisation; the fingerprints are the truth. After a
differential, xauth:mirror:sync re-checks them, and if the table still
differs it prints escalating to a full refresh and repairs it in the same
run. The refresh swaps inside one transaction, so readers never see a half-filled table.
When a table reports drift
Rows are synced, but the table is not an exact copy, and the command exits non-zero. A column the manifest does not mirror is never written again and keeps its old values for good — extra data that looks like data.
| Finding | Meaning | Do |
|---|---|---|
columns_extra | the local table has columns outside the manifest | add them to the manifest, or rebuild |
columns_retyped | a column's type differs from the console's, or a varchar is narrower | rebuild |
php artisan xauth:mirror:sync --replace-tables --table=um_branches
That drops the table and recreates it from the console's definition, so it asks
first (--force skips the question). It is never automatic: a column outside the
manifest may be read by your code, and only you know whether to mirror it or drop it.
One app, several databases
An app with more than one database (a core database plus an HR one) declares one
profile per target in config/xauth_client.php. The top-level
mirrors keys are the default profile; every other target goes under
profiles.
'mirrors' => [
'tables' => [ /* the default profile: the app's own connection */ ],
'profiles' => [
'activehr' => [
'connection' => 'activehr', // required: never falls back to the default
'namespace' => 'App\\Models\\ActiveHr\\Xauth',
'path' => app_path('Models/ActiveHr/Xauth'),
'tables' => [ /* same shape as above */ ],
],
],
],
A profile must name its connection. Falling back to the default would write one product's mirror into another's database, so it throws instead.
Commands
| Command | What it does |
|---|---|
xauth:mirror:sync | Brings every profile in line, every table. --profile=<name> picks one database |
xauth:mirror:verify | Compares all four fingerprints for every profile. Exits non-zero if anything differs — for cron and CI |
xauth:mirror:status | Per profile and table: local vs console rows, watermark, last sync |
xauth:mirror:sync --dry-run --schema-only | What the schema phase would do, including drift. Writes nothing |
xauth:mirror:sync --full --table=um_x | Force a full refresh of one table |
Each profile is isolated: a database that is down fails its own pass, the rest still run, and the command exits non-zero.
What keeps it correct
| Trigger | Role |
|---|---|
the scheduled xauth:mirror:sync | The guarantee. It must exist |
| the console's change callback | Speed only. It carries ids, never data, and is signed |
| identity write-through | Refreshes a user right after the app wrote it |
| a manual sync | An engineer's repair |
Where each customer runs on its own host, the console's callback URL may contain
{subs_code}; it is filled in per customer and each one is told only its own changes.
Rules that must always hold
- Never authorise from the mirror.
- Never write a mirror table by hand and never add columns to it. Keep your own data in your own table with a foreign key to the mirrored id.
- Never hide a failing command's output. It names the cause and the exact fix.
- A named profile always has an explicit
connection. - Never make correctness depend on a callback or a queue. The scheduled pull is the guarantee.