Upgrading
Upgrade an existing Signals installation — migrations, cache schema rotations, queue and Octane restarts, language packs, seeded data, and the breaking changes to check first.
Overview
Self-hosted beta upgrades use php artisan signals:update on the server. The owner-only Admin → System → Updates page shows CLI instructions and durable recovery status; web installation is hidden by default. The CLI requires a verified PostgreSQL backup before installing code or migrating, and holds maintenance after a failed mutation until journaled recovery and readiness pass.
See Updates and the failed-update decision table before upgrading. Do not run a blind artisan up, delete an unresolved journal, or reverse migrations to recover. A new ordinary update is refused until recovery is resolved.
Signals Cloud installations are upgraded for you. Cloud users still need to re-create existing calendar subscriptions; the installation steps below apply to self-hosted installations.
Before you start
- Prepare verified database backups. Install compatible
pg_dump,pg_restoreandpsqlclients, allow enough disk space, and rehearse a restore into a disposable database. Stop in-flight workers, scheduled tasks and other writers. The CLI enables maintenance, then takes and verifies a dump before code/schema mutation and records the connection identity and migration inventory in the durable journal. A failed backup keeps maintenance on; fix the cause and recover with--resume. Only an explicit, journaled--without-backupwaives this requirement. The upgrade does not reverse migrations. Several migrations on this release rewrite existing rows (see Migrations that change existing data). - Check the installation is healthy with
php artisan signals:status— a failing Redis or storage connection before an upgrade will still be failing after it. - Note the installed version. It is
config('signals.version'), shown at the top of the Updates page and byphp artisan signals:update --check. - Read the release notes for every version between the installed one and the target, not just the newest. Breaking changes accumulate.
- Check your plugins. Enabled Composer plugins are resolved by the same
composer requirethat pulls the new core, so an incompatible plugin constraint will fail the download step before anything is migrated.
Plugin SDK 0.9 transition
This release identifies the pre-marketplace plugin SDK as 0.9.0, separately from the product version. For this release only, SDK 0.9.x also accepts the legacy signals_version: "^1.0" constraint (including ^1.0.0) during install, enable, update, registry selection and boot. Existing enabled plugins continue to boot. The first boot records one durable deprecation notice per installed plugin and shows it on Admin → Plugins; subsequent boots do not repeat the notice or create rejection audit rows. Authors should publish manifests declaring signals_version: "^0.9" before the next release. New scaffolds, Manifest Tools and first-party fixtures use ^0.9.
Other constraints retain their normal compatibility checks: ^2.0, for example, is refused on SDK 0.9.x. An enabled plugin that fails compatibility preflight is marked Disabled with the reason shown on Admin → Plugins. Its rejection is audited once when it is disabled. Install a compatible version and explicitly enable it again; successful enablement clears the reason. Run the release migrations before restarting workers or serving upgraded requests, so the durable notice and disabled-reason columns are available.
Plugin HTTP requests now validate resolved addresses and pin the cURL connection on every send. Redirects remain disabled, TLS certificate verification is required, and proxies are bypassed. Internal endpoints can use the existing api.allow_private_webhook_targets setting, but still need a trusted TLS certificate. Install the internal CA in the server trust store when needed. Plugin middleware and callbacks execute trusted PHP in the application process; these checks do not isolate arbitrary plugin code.
Upgrading manually
Use the CLI updater for beta, including recovery:
php artisan signals:update --check
php artisan signals:update
# If interrupted or failed, keep maintenance on:
php artisan signals:update:recover --status
php artisan signals:update:recover --hold
# After correcting the cause, resume the remaining phases:
php artisan signals:update:recover --resume
For a source rollback or database restore, follow the failed-update decision table. --restore uses the recorded dump after you reinstate the source release and saved composer.lock and composer.json; --mark-restored confirms a completed manual restore and source Composer install. Both require confirmation and a strict readiness check before public traffic returns. A failed recovery stays in maintenance.
Manual Composer deployment is an operator-controlled path outside the automated journal. Keep traffic held, take and verify a backup before changing code, preserve the source release/lock, then run migrations, run php artisan signals:project-costs:backfill-provenance (dry-run report) and --apply, clear caches and restart workers. Run php artisan signals:update:readiness and investigate any failure before considering artisan up. Prefer the journaled updater for beta.
Project assignment cost provenance runs after migrations: first a dry-run report, then application of exact, unambiguous links. Amounts and timestamps stay unchanged. Review ambiguous or non-replayable rows for manual repair. The report calls out cancelled assignments because linking their costs excludes the uninvoiced remainder from future billing. The updater runs both commands while maintenance mode remains active. Run the same steps against each tenant database when deploying tenant installations.
Two things the automated paths deliberately do not do:
- They do not run npm. The Composer package ships built assets. If you installed from a git clone and build the frontend yourself, run
npm ci && npm run buildafter the Composer step and before lifting maintenance mode. - They do not re-cache config, routes, or views.
optimize:clearempties those caches; a production installation that caches them should runphp artisan optimizeagain once the new code is in place.
Tip:
--secretgives you a bypass link ({app-url}/{secret}) so you can verify the upgraded site while it is still down for everyone else.
Migrations
Run migrations with php artisan migrate --force. Every upgrade path does this for you.
Two properties are worth knowing before you run them:
- Some migrations are not transactional. The base-currency migration declares
public $withinTransaction = false, because a schema change plus a full-table backfill must not share one transaction. If it fails part-way you may need to inspect the affected tables rather than relying on a rollback. - Some migrations do real work. They are not all
ALTER TABLE. The table below lists the ones on this release that read or write existing rows.
Migrations that change existing data
| Migration | What it does to existing rows |
|---|---|
add_currency_code_to_warehouses_table |
Adds a nullable warehouses.currency_code. NULL means "inherit company.base_currency", so existing warehouses are unchanged. |
add_base_currency_amounts_to_document_tables |
Adds *_base, base_exchange_rate, and base_rate_date columns to rentals, invoices, credit_notes, purchase_orders, and rental_costs, then calls signals:rebuild-base-amounts for those tables. The rebuild does not fill every row: it follows the rules below, so some rows keep empty base amounts and are reported. |
add_times_are_utc_legacy_to_rentals_table |
Flags every existing rental times_are_utc_legacy = true, and backfills warehouses.timezone with the company clock wherever it was NULL. |
add_is_default_to_catalogue_item_rates_table |
Adds catalogue_item_rates.is_default and backfills one default per catalogue item and transaction type, choosing the row the resolver was already picking. Adds a partial unique index so only one default can exist per scope. |
backfill_rental_pk_on_item_removed_events |
Rewrites historical ItemRemoved event payloads to carry their owning rental id. It fails loudly if a removal cannot be traced to a rental rather than guessing. |
add_rental_status_lookup_index_to_verb_events |
Creates a PostgreSQL expression index on verb_events. No-op on other drivers. |
add_project_attachments_to_rental_costs_and_resource_assignments |
Makes rental_id nullable on rental_costs and resource_assignments and adds a check constraint: a row belongs to exactly one rental or one rental project, never both and never neither. |
add_project_source_and_cost_tracking_to_invoices |
Adds invoices.rental_project_id with a constraint that a sourced invoice points at a rental or a project, never both. Source-less manual invoices remain valid. |
Older releases stored rate 1 for a document whose currency had no usable exchange rate. The rebuild now leaves such a document with empty base amounts and reports it; it never fills a missing rate with 1. Preview first, then run it and keep the report:
php artisan signals:rebuild-base-amounts --dry-run
php artisan signals:rebuild-base-amounts --report=storage/base-amounts-report.json
The command exits 1 when a row fails for an unexpected reason, and with --strict when any row is reported. A posted document whose base currency is recorded neither on the row nor on its events is reported and keeps what it had; the current company base currency is never used for it. A foreign document without recorded money units is refused in the same way after a currency scale change.
A posted invoice, credit note or purchase order created before this migration has no base currency on its row or its events, so the rebuild refuses it on every run and its base amounts stay empty. With --strict the command then exits 1 on every run: there is no automatic fix, and the report lists those rows. The same is true for a foreign draft voided before issue that has no stored rate or rate date.
A voided invoice already in the database keeps its old base outstanding until the rebuild (or a replay) sets it to 0.
The add_invoice_subtotal_and_paid_base_amounts backfill now gives a foreign draft or pro-forma with no recorded base currency empty base balances, not its document amounts (which were in the wrong currency). Only a same-currency invoice gets its document amounts. Run the rebuild afterwards to fill them.
A database that ran the earlier version of that backfill can still hold an issued foreign pro-forma with no base_currency_code whose document amounts were stored as base money (for example EUR amounts under a GBP base). The rebuild treats it as posted with no recorded base currency, so it reports it on every run and keeps those values. To correct it, set its base_currency_code to the company base currency it was issued in, then run the rebuild: it converts the pro-forma at its stored rate, or empties its base amounts and reports it when the stored rate is the old fallback of 1. Add the missing rates under Admin → Money & Pricing → Exchange Rates, then run it again. See Multi-Currency & Tax.
Caches
optimize:clear empties Laravel's config, route, view, and event caches. Signals' own application caches need slightly more thought.
Cache schema rotations
Several cached read models embed a schema version in their cache key — for example rental_project:registers:v7:{id}:acl:{hash}:{generation}. When a release changes the shape of one of those payloads it bumps the version, and every key from the previous version is simply never read again.
This release bumps the rental-project register schema from 4 to 7, as the composed money, crew, and transport payloads changed shape three times. The practical consequences:
- You do not need to flush anything for correctness. A stale payload can never be served under a new schema, because the key it lives under is no longer generated.
- Orphaned keys linger until their TTL expires. On a large installation with a memory-bound Redis,
php artisan cache:clearafter the upgrade reclaims that space immediately. - A tagged cache store is not required. The version segment is part of the key, not a tag.
The same pattern applies to the project rollups (rental-projects:rollup:v5:…) and account registers (accounts:registers:v2:…).
Domain caches
Signals invalidates most cached data by bumping a per-domain generation counter rather than by deleting keys. Nothing about an upgrade requires you to touch those counters, but Admin → System → Caching exposes manual per-domain invalidation if you want to force a cold read after a data-affecting migration. See Caching Strategy and Caching.
Queue workers and Octane
Queue workers must be restarted. A long-running worker holds the old code in memory and will keep executing it against the new database schema — the single most common cause of post-upgrade errors. php artisan queue:restart signals every worker to exit gracefully after its current job; your process supervisor is responsible for starting them again. All three upgrade routes issue this command.
If you run Laravel Octane, its workers are long-running application processes with the same problem, and queue:restart does not touch them. Reload them explicitly after the upgrade:
php artisan octane:reload
Octane also imposes a standing rule that matters when you add plugin code around an upgrade: never keep request-specific state in singletons or static properties. Use $this->app->scoped() for per-request services. See Caching Strategy.
queue:restart signals every running queue:work process to restart after its current job.
Language packs
New UI copy arrives with a release as JSON seed packs under database/seeders/data/lang/{locale}.json. Migrations do not load them — the seeder does. After upgrading:
php artisan db:seed --class=LanguageTranslationSeeder --force
The seeder is idempotent and never overwrites a non-null value, so translations your operators have edited in Admin → Data → Languages survive the upgrade. Only keys that are missing or empty are filled from the pack.
Confirm the result:
php artisan signals:lang-coverage --fail-under=100
You only need php artisan signals:lang:sync and php artisan signals:lang-backfill if you are adding your own translatable strings on top of the release — they are authoring tools, not upgrade steps. The full workflow is on the Languages page.
Seeded data
Signals' default seeders are idempotent and safe to re-run, but an upgrade does not run them for you. Where a release adds seeded reference data — a notification type, a document template, a system report, a rate-specification preset — you pick it up by running that seeder explicitly:
php artisan db:seed --class=NotificationTypeSeeder --force
Two behaviours to keep in mind:
- Operator edits are preserved. Seeders re-align structure, not content: an edited email template or communication template is not reverted on reseed.
- Plugin-owned seeded rows follow the plugin. Enabling, disabling, or updating a plugin re-aligns its notification types and templates with its enabled state; you do not reseed those by hand.
The complete list of seeders, what each one writes, and which are safe to re-run is on the Seeders page.
VAT Return and Tax by Treatment cutover
To move unchanged system tax reports from gross invoice-only snapshots to signed invoice and effective credit-note facts, run:
php artisan db:seed --class=ReportSeeder --force
Only exact unchanged legacy system definitions are replaced. Edited or tenant-owned reports remain available for maintainer review, and existing report IDs, pins and schedules are retained. The new definitions group by document currency and have no cross-currency grand total. Review any tax-report schedule thresholds that depend on a grand-total value; per-currency threshold handling remains a follow-up.
Breaking changes in this release
Self-hosted embedding endpoints require explicit local trust
Custom embedding endpoints now default to EMBEDDINGS_ENDPOINT_TRUST=public: HTTPS on port 443 with public destinations only. Existing HTTP, loopback, private-IP, internal-host or local-port endpoints may be disabled after upgrading. The AI tab shows the specific refusal reason, and repeated checks log that reason once per active request with only the scheme and host.
For an endpoint you operate on your own network, set EMBEDDINGS_ENDPOINT_TRUST=local, rebuild cached configuration as part of deployment, and restart queue/Octane workers. This permits HTTP and approved local ports while still refusing metadata, link-local, multicast and reserved targets. Cloud always enforces public trust. Alternatively move the endpoint to a valid public HTTPS address. Existing consent is retained; changing the destination still requires matching owner consent.
Configuration and consent checks do not resolve DNS. Each actual provider call resolves and pins its destination once, outside the embedding action’s row locks and write transaction. Temporary DNS/provider failures keep stored vectors, leave keyword search available with the paused notice, and retry individual sync items without cancelling the batch. See AI embeddings.
Calendar subscriptions must be re-created
Existing calendar subscriptions must be re-created after this upgrade. Old signed-only feed URLs are rejected immediately; there is no compatibility window. In Settings → Calendar or the calendar’s feed modal, choose Create link for each personal, global or staff feed you use, then replace the old subscription in your calendar app with that new URL. Copy the URL when it is shown. Administrators and owners must create any global or per-staff links they need.
Links expire after 90 days and can be rotated or revoked individually per issuer and scope. Rotation disables the old URL immediately, so update the external subscription each time. Every fetch, including conditional requests, rechecks the issuing user’s current active status and access; loss of access can stop a subscription before expiry. See Calendar for the controls and security model.
Warehouse currency is now an explicit field, and changing it is guarded
Warehouses gain a currency_code. Leaving it empty means "inherit the company base currency", so no existing warehouse changes behaviour on upgrade. What changes is what happens when somebody edits it afterwards.
A warehouse's effective currency can no longer be changed — nor can a currency be deleted out from under one — while either of these is true:
| Blocker | Message |
|---|---|
| The warehouse has open rentals (Quote or Order state, excluding Lost / Dead / Cancelled / Complete) | The warehouse currency cannot be changed while it has open rentals. |
| Warehouse-scoped rate cards remain in the old currency | The warehouse currency cannot be changed because warehouse-scoped rate cards remain in :currency (:count found). Run php artisan signals:audit-rate-currencies before retrying. |
A change that leaves the effective currency unchanged — setting currency_code to the company base, or clearing it on a warehouse that already inherits that base — is always allowed.
Catalogue item rates must be tagged in their scope's currency: a warehouse-scoped row matches that warehouse, an all-warehouse row matches the company base. Audit an existing installation before you start moving warehouses onto their own currencies:
php artisan signals:audit-rate-currencies
The default run is read-only. --fix=retag stamps the expected currency onto violating rows while keeping the stored number — this changes real prices and is only correct for rows that were mis-tagged all along.
Rentals are also affected: moving a rental to a warehouse with a different currency is refused once the exchange rate is locked, tax figures are locked, or the rental has invoices. See Warehouses and Multi-Currency & Tax.
Rental times are warehouse-local, and older rentals carry a legacy marker
Rental datetime columns still store UTC instants, but the rental form now reads and writes them as warehouse-local wall clock. This is a forward-only cutover.
Rows written before the cutover were stored as whatever the operator typed, so they cannot be reinterpreted safely. The migration flags every existing rental times_are_utc_legacy = true. Those rentals:
- display their stored values raw, behind a Times recorded before timezone support badge, everywhere they appear — tables, detail pages, documents, exports;
- convert in full the first time they are saved through the rental form, including dates that were not touched on that visit, after which the flag is retired;
- are positioned on the rental diary from their recorded wall clock, unconverted, in either grid mode.
Conversion is deliberately all-or-nothing and deliberately lazy. There is no bulk conversion tool — a half-converted rental would display wrong the next time it was opened. Reassigning a legacy rental's warehouse directly leaves its raw values untouched until that normal full-form conversion happens.
The same migration backfills warehouses.timezone with the company clock wherever it was NULL. On a UTC company that is a no-op. Anywhere else, every warehouse-local consumer that previously fell back to UTC — document and stock-check numbering and their local-midnight rollover, rendered PDFs, and the availability engine — now runs on the company clock instead. Set each warehouse's zone explicitly under Admin → Setup → Warehouses wherever a site is not on the company clock.
Ordered At and Quote Expires At are record milestones, not warehouse-floor times, and are outside this conversion. The API continues to accept and return UTC instants. See Rentals and Rental Diary.
Catalogue item rates have an explicit default
catalogue_item_rates.is_default makes the resolver's chosen default explicit instead of implied by priority ordering. The migration backfills one default per catalogue item and transaction type, picking the row the resolver was already selecting, so resolved pricing does not move on upgrade.
After the upgrade, the flag is authoritative within its scope: creating a rate only claims the default flag when it is asked for, and any demotion it performs stays inside the same scope — adding a warehouse-scoped rate no longer flips resolved pricing for every other scope. A partial unique index enforces one default per catalogue item and transaction type. See Rate specifications and Pricing & Discounts.
Backfill historical base currency identities
This release ships the base-identity backfill together with stricter readers. Before enabling the release, restore a beta database backup into an isolated rehearsal installation and run:
php artisan signals:base-currency-identity:backfill --dry-run --strict --report=storage/base-currency-identity-report.json
The dry run loads event states and validates proposed identity events without recording them. It writes no database rows, events, audits or findings. A rejected or unloadable stream is reported as write_failed.
After reviewing the dry-run report, run the real command on the same isolated restored dump:
php artisan signals:base-currency-identity:backfill --strict --report=storage/base-currency-identity-real-report.json
Inspect base_currency_identity_findings for reason_code = 'write_failed' and for status values ambiguous or unresolved. Both rehearsal runs must complete before deployment. A real --strict rehearsal exits 1 while unresolved or ambiguous rows remain; that result is expected and does not mean the command crashed. Use table_name and row_id to locate each affected row, then read source, evidence and reason_code to distinguish missing proof, conflicting proof and a rejected write. write_failed or identity_write_conflict requires investigating the recorded error and rerunning the command after repair. resolved rows were stamped; informational append_only and assumption rows were reported without being changed. The real rehearsal also exercises projection handlers, constraints and audits that a dry run cannot execute. The migration is non-transactional: earlier rows may already be committed if a later write fails, and the persisted findings identify where to resume. Review the JSON and table output to size unresolved and ambiguous rows before deployment. --table=invoices (or another identity table) limits a rehearsal. --strict exits 1 when any identity remains unresolved or ambiguous; without it, unresolved data is reported without failing the run. Informational append_only payment findings and assumption legacy billing-run findings do not fail strict mode. Unexpected write failures always exit 1.
Migration 2026_09_30_171933 creates the findings table; 2026_09_30_171934 runs the command outside a migration transaction, recording deterministic identities with a system actor. Empty databases are a no-op. The command can be rerun safely: stamps are NULL guarded and findings are upserted by table and row ID. Existing base identities and document timestamps remain unchanged.
Unresolved means the stored events, money-unit facts, accepted snapshots, invoice parent and contemporaneous settings cannot prove the historical base. Ambiguous means admitted evidence disagrees. These rows retain NULL; current settings are never substituted for posted history. Reader refusal may make dashboards incomplete or block credit decisions until the evidence is repaired. Adding or editing costs on unresolved rentals is refused with rental_base_currency_unresolved. Append-only payments are findings only, and historical billing-run bases are marked as migration assumptions. Rollback drops and can recreate the findings table; it never clears stamped document identities. Reruns also mark stale unresolved/ambiguous findings resolved with source observed when a separate first-stamp path has recorded the identity.
An operator can resolve one mutable row after independently confirming its original base:
php artisan signals:base-currency-identity:backfill --resolve=rentals:123=GBP --reason="Confirmed against the original ledger"
This records source operator, the required reason and a system actor in the finding and audit evidence. Supply --actor=<user email or id> to attribute a decision to a known user; unknown users are refused. Event-sourced rows receive the corresponding identity-recorded event, so full replay retains the decision. --dry-run previews and validates it. Already stamped rows, append-only payments and billing-run assumptions are refused. The requested code must agree with any recorded invoice/rental parent identity. Resolve the rental before its open costs, then rerun the backfill; open costs can inherit that parent identity. Resolving a settled rental does not propagate identity to its legacy costs: each cost must have its own deterministic evidence or its own --resolve=rental_costs:<id>=<CODE> --reason="<text>" decision. This permits new or changed costs previously refused with rental_base_currency_unresolved once their rental is resolved.
Repair and recovery
If a read model looks wrong after an upgrade, the event stream — not the projection table — is the authority. Signals keeps rentals, invoices, purchase orders, and virtual stock intakes as event streams, and the tables the UI and API read are projections of those streams.
| Command | When to reach for it |
|---|---|
php artisan signals:projections:repair |
Preview or repair one projection or aggregate family. Start here. |
php artisan signals:projections:rebuild |
Wipe and replay projections in scope from the event store. The supported recovery path after a projection bug is fixed. |
php artisan signals:rebuild-base-amounts |
Recompute denormalised base-currency amounts. Display conversion only — locked rentals keep their snapshotted rate. |
php artisan signals:audit-rate-currencies |
List catalogue item rates whose currency does not match their scope. |
Replay is side-effect free: webhooks, notifications, plugin webhooks, and shortage detection all short-circuit while the event store is replaying, so a rebuild does not re-notify your customers. Take a backup first regardless — a rebuild wipes the projections in scope before re-deriving them.
Move existing attachments and exports to private storage
This release writes new attachments and exports to a private disk. Existing
public files remain reachable until migrated. Back up the database and files,
put the tenant into maintenance, and pause workers that can write or delete
attachments and exports. Leave the old filesystem configuration in place while
migrating so legacy export paths still resolve to their original disk. Set
ATTACHMENTS_DISK only when a different private destination is needed.
php artisan signals:files:privatize --dry-run
php artisan signals:files:privatize
Run in each tenant's context on Cloud using signals:tenants:run. Both commands
report moved, skipped and failed counts; dry run changes no files or rows. Any
failure returns a nonzero exit code. Before moving files, the command drains
outstanding account and rental file publication and deletion intents in delivery order.
If an intent cannot finish or has an active worker claim, the command refuses
to migrate any files; resolve the failure and rerun with writers still paused.
This prevents a delayed legacy publication from recreating a public original
after migration. Dry run only reports these pending intents.
The command copies public attachments,
thumbnails and export-job files, compares SHA-256 checksums of source and
stored destination streams, then rewrites each record. It also moves standalone
public exports/ files to the private disk under their existing names.
Public originals are deleted only after verification and record persistence, and only once no unmigrated record still references them. An interrupted copy is replaced and verified on retry only if no private record references it. A fresh checksum mismatch or a mismatched referenced destination leaves the original and record intact. A missing thumbnail is reported and its reference is cleared only after the main file is verified; a missing main file still refuses migration. Fix other reported problems and rerun; completed moves are skipped and interrupted cleanup is retried. Do not manually remove unverified originals. Retain the private disk configuration after the move, resume workers and exit maintenance only when the command succeeds. Purge any CDN cache of the old public attachment and export URLs. Check an old attachment, a new upload and an export download. Public icons and branding are deliberately not moved by this command.
Verifying the upgrade
Regenerate stored account and catalogue icon thumbnails after deploying this release. The command reads each original icon and replaces only its derivative thumbnail at the new 300×300 size:
php artisan signals:regenerate-icon-thumbnails
Run it once per deployment after shared storage is mounted. New uploads are generated at 300×300 automatically.
php artisan signals:status— infrastructure and application health.- Admin → System → System Health — live database, Redis, S3, queue, and PHP diagnostics.
- Confirm the queue is consuming: a job queued from the UI should complete rather than sit.
php artisan signals:lang-coverage --fail-under=100if you maintain non-English locales.- Spot-check a rental created before the upgrade — it should carry the Times recorded before timezone support badge and read exactly as it did previously.
Personal access token lifetime
Personal access tokens now expire after api.token_expiration_days (90 days by default, at most 3,650 days). Legacy zero or negative settings use 90 days. Existing tokens without a deadline track the time the upgrade migration runs; their creation date does not shorten the upgrade window. Tokens issued without an explicit deadline derive expiry from their issue or upgrade time and the current default. Raising the default extends those tokens, including restoring one that expired under the shorter default. Lowering it immediately expires policy-based tokens older than the new lifetime. Explicit administrator-set deadlines remain fixed and are never extended by this setting. Rolling back does not restore permanent credentials. Expiry and last use are visible in the API console and token list endpoint.
Production sessions now require HTTPS, secure, HTTP-only, same-site cookies and encrypted session storage. A production install served over plain HTTP cannot retain a session. Existing sessions may require signing in again after upgrading. Changing a password or confirming an email change revokes personal access tokens and OAuth/MCP grants, disconnecting integrations until they authenticate again. Local HTTP development remains supported.
Related
- Updates — CLI upgrades, verified backups and the failed-update decision table
- Installation — first-time setup
- Seeders — what each seeder writes and which are safe to re-run
- Caching Strategy — cache-aside architecture, TTLs, and invalidation
- Warehouses — warehouse currency, timezone, and the scoping access model
- Multi-Currency & Tax — base amounts, exchange rates, and the rate-currency invariant
Update backup encryption and expiry
Automated self-host update backups are encrypted using authenticated AES-256-GCM chunks. SIGNALS_UPDATE_BACKUP_KEY supplies the dedicated key (at least 32 random bytes, optionally prefixed with base64:); otherwise the installer uses APP_KEY. Keep the original key securely outside the database backup. Each archive header and metadata record its format version and key ID. Reinstating the original key is required to restore an archive after key rotation. Composer copies and metadata remain private companion files.
SIGNALS_UPDATE_BACKUP_RETENTION_DAYS defaults to 30 and must be at least 1. After readiness passes and maintenance ends, the updater prunes expired backups and their companions. A pruning failure is reported without failing the update. Run php artisan signals:update:prune-backups --dry-run to inspect the count, then omit --dry-run to prune; --days=30 overrides retention for that run. The durable update lock prevents concurrent updates and pruning. Regardless of age, pruning keeps the current or latest completed journal backup, the previous protected backup until a replacement has completed and verified, and the newest verifiable archive. If no archive verifies, pruning refuses without deleting anything.
Legacy plaintext .dump archives remain readable and expire under the same rule; existing files are never rewritten in place. Verification and restore decrypt to private temporary files beside the archive and remove them even on failure. Allow disk space for the decrypted dump and restore SQL. Erased subjects remain in backup bytes until those backups are pruned. Before admitting restored data, follow the account restore erasure checklist.
Manual database restore
For a manual restore, keep maintenance on and stop all other writers. Preserve the failed database for investigation. Read <backup>.metadata.json (or backup_metadata in the journal) before starting: record every application schema and each extension's name, schema and version. Install the matching extension packages on the PostgreSQL server first. Use compatible client tools, preferably the same major version as the server; this direct manual sequence does not apply the automated recovery's PostgreSQL 15/16 SQL compatibility adjustment.
The following is the working sequence from the restore drill. Replace RESTORE_DATABASE, HOST and USER with the recovery connection, and /secure with an existing private directory. Supply the port through PGPORT if it is not 5432, and credentials through your protected PostgreSQL environment or password file. Use a new database; this procedure does not clear or merge an existing one. Create any additional recorded schemas before installing extensions because the TOC filter excludes all schema containers. The extension SQL below matches the drill's inventory: btree_gist 1.8, pg_trgm 1.6 and vector 0.8.1 in public, with plpgsql 1.0 already in pg_catalog. Replace it with the backup's recorded inventory when that differs; do not substitute newer versions or ignore a mismatch.
Reinstate the original APP_KEY or SIGNALS_UPDATE_BACKUP_KEY before decrypting. Encrypted archives cannot be passed directly to pg_restore. Run this block in Bash; it stops on the first error and removes the private plaintext and TOC files on success or failure:
(
set -euo pipefail
umask 077
test ! -e /secure/restore.dump
test ! -e /secure/restore.toc
test ! -e /secure/restore.list
php artisan signals:update:decrypt-backup /secure/update.dump --output=/secure/restore.dump
trap 'rm -f /secure/restore.dump /secure/restore.toc /secure/restore.list' EXIT
createdb -h HOST -U USER --template=template0 RESTORE_DATABASE
psql -X -h HOST -U USER -d RESTORE_DATABASE --set=ON_ERROR_STOP=1 <<'SQL'
CREATE EXTENSION btree_gist WITH SCHEMA public VERSION '1.8';
CREATE EXTENSION pg_trgm WITH SCHEMA public VERSION '1.6';
CREATE EXTENSION vector WITH SCHEMA public VERSION '0.8.1';
SQL
pg_restore --list /secure/restore.dump > /secure/restore.toc
python3 -c 'import re; from pathlib import Path; source = Path("/secure/restore.toc").read_text(); Path("/secure/restore.list").write_text(re.sub(r"^\d+;\s+\d+\s+\d+\s+(?:EXTENSION\b|SCHEMA\b|COMMENT - (?:EXTENSION|SCHEMA)\b).*\n?", "", source, flags=re.M))'
pg_restore -h HOST -U USER --dbname=RESTORE_DATABASE \
--no-owner --no-privileges --exit-on-error --single-transaction \
--use-list=/secure/restore.list /secure/restore.dump
psql -X -h HOST -U USER -d RESTORE_DATABASE --set=ON_ERROR_STOP=1 <<'SQL'
SELECT e.extname AS name, n.nspname AS schema, e.extversion AS version
FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace
ORDER BY e.extname;
SELECT id, migration, batch FROM public.migrations ORDER BY migration, batch, id;
SQL
)
The filter removes only SCHEMA, EXTENSION and their COMMENT entries, preserving the pre-created containers and extension members. --exit-on-error --single-transaction makes the archive load atomic; do not add parallel jobs. See the PostgreSQL restore options. A failed attempt must stay held for investigation; do not continue to verification or attest success. The decrypt command refuses an existing output path and authentication failures, publishes only authenticated mode 0600 plaintext, and removes partial temporary output itself. For a legacy plaintext archive, replace the decrypt line with a private copy to the fresh /secure/restore.dump path; retain the original backup.
Before accepting the restore, compare every returned extension name/schema/version and migration row (ID, name and batch) with the backup metadata, adjusting public.migrations to the recorded application schema if needed. Also compare recorded application row counts and monetary totals by currency, and check representative application reads against the source release. A matching migration count alone is insufficient. Reapply any required schema grants and application-object grants separately: --no-privileges does not restore them, and --no-owner makes the restoring user own application objects. Complete the restore erasure admission checklist before releasing traffic; the original drill's health/integrity and erasure failures remain unresolved evidence.
Reinstate the exact source release and saved composer.lock and composer.json, then run composer install. With an update journal, arrange the database cutover so the verified restored data is available under the recorded host, port, database, application schemas and username, then use php artisan signals:update:recover --mark-restored only after all manual verification and admission work is complete. Recovery checks the recorded connection and source fingerprint and runs readiness before releasing maintenance. For a manual deployment without a journal, keep traffic held while clearing caches, restarting workers and running php artisan signals:update:readiness; investigate failures before release. See the recorded-backup recovery procedure.