Operations
MarkdownUpgrade
Stay on a newer Externa release after first install. Code comes from Git (or a Docker image tag). php artisan externa:upgrade runs migrate / permissions sync / cache clear by default — it does not pull code unless you pass opt-in --sync-upstream.
Mental model (read this first)
| Fact | Meaning |
|---|---|
composer create-project | Install once. Copies the app tree into a new directory. It is not how you receive later Externa releases. |
| Your git remote | After you git init / push that tree to your repo, that remote tracks your commits — it does not auto-update from qiick-io/externa-core. |
Packagist type: project | Same scaffold story: Composer does not replace your app on later composer update. |
| Recommended update channel | Git: clone (or add upstream) and checkout / merge a release tag vX.Y.Z. |
php artisan externa:upgrade | Default: after code is already on the new release — migrate --force, permissions:sync, optimize:clear. No composer/npm. Opt-in: --sync-upstream=vX.Y.Z can merge the official tag first. |
| Docker | Pull / pin a newer GHCR image tag; keep named volumes; then migrate inside the container (or rely on RUN_MIGRATIONS). |
create-project ≠ update channel
Operators who started with Packagist and then pushed to their own GitHub/GitLab still need a deliberate code update (git tag / upstream merge / --sync-upstream, or Docker image bump). Point them here from Packagist, Installation, Quick Start, and the core README.
Security: PHP 8.4.27 / 8.5.12 (CVE-2026-109618 / CVE-2026-109619)
Upgrade the host PHP runtime to 8.4.27+ or 8.5.12+ (CVEs in PHP itself, not Externa — GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp). From externa-core 1.2.2, Composer requires php: ^8.4.27 (#248). After upgrading, restart php-fpm and verify CLI + FPM. Official ghcr.io/qiick-io/externa-core:1.2.2 and latest ship PHP 8.4.27 with hardened php.ini — re-pull and recreate containers; source builds use docker build --pull --no-cache. Full notice: discussion #247. Keep session.upload_progress Off after patching — Deployment — PHP runtime hardening.
Version-specific notes
1.3.x → 1.4.0
PostgreSQL 15+ required
On PostgreSQL, 1.4.0 rebuilds the collections_items_values unique index with NULLS NOT DISTINCT (#278). That syntax needs PostgreSQL 15 or newer; on 14 the migration fails. Upgrade the database server first. The same migration deletes older duplicate rows for non-translatable values (it keeps the newest row per item / field / position), so take a backup before migrating. SQLite, MySQL, and MariaDB skip this migration. The bundled Compose files already use postgres:16-alpine.
Upgrade steps (source)
- Back up the database and
storage/— Backup & restore. - Checkout / merge tag
v1.4.0(orphp artisan externa:upgrade --sync-upstream=v1.4.0). composer install --no-dev --optimize-autoloader— new packages includelaravel/mcp,spatie/laravel-google-cloud-storage, andazure-oss/storage-blob-laravel.npm ci && npm run buildphp artisan externa:upgrade(add--forcein production) — runsmigrate --force,permissions:sync, andoptimize:clear.- Restart queue workers, scheduler, Horizon (and Reverb / Pulse if you run them). The scheduler now also runs
ai:prune-tool-auditdaily. - Review the behaviour changes below, then smoke-test login, one collection, the file manager, and AI chat.
Docker: pull ghcr.io/qiick-io/externa-core:1.4.0, recreate containers, and run php artisan externa:upgrade --force in the app container (or rely on RUN_MIGRATIONS=true, then run php artisan permissions:sync). See the www-data and DB_PASSWORD notes below.
New AI settings and removed packages
- Assistant reasoning and context window (#570): the Assistant now asks real providers for medium reasoning effort, which is slower and uses more tokens. Tune it or turn it off with
OPENAI_REASONING_EFFORT,ANTHROPIC_REASONING_EFFORTandGEMINI_REASONING_EFFORT(low/medium/high; any other value or empty = off).LOCAL_AI_REASONING_EFFORTis unset (none) by default.AI_ASSISTANT_PROVIDERpicks the Assistant's provider (defaultlocal), andOPENAI_CONTEXT_WINDOW/ANTHROPIC_CONTEXT_WINDOW/GEMINI_CONTEXT_WINDOW/LOCAL_AI_CONTEXT_WINDOWset the context size shown next to an answer's token usage — AI configuration. None of these need to be set; the defaults apply when they are missing. - Removed packages (#520, #568):
laravel/sail,@headlessui/react, the unused@radix-ui/react-*packages (the UI imports fromradix-ui) and the Wayfinder Vite plugin are gone. Runcomposer installandnpm ciafter pulling; if your fork imports any of them, add them back in your owncomposer.json/package.json.
What externa:upgrade now does for you
files.effective_accessbackfill (#186, #434): the migrations add and compute the denormalized access column for every existing file. No manual step.php artisan files:backfill-effective-accessstays available and is safe to re-run. A file whose access was never computed is now hidden from roles withoutread_privateinstead of being treated as public.- API key owners (#215, #439): new
api_keys.owner_id. Each existing key is owned by its creator when that user is still active; otherwise by the first active super-admin, then admin. Keys stay ownerless only when no admin exists. Effective access becomes key role ∩ key scopes ∩ owner permissions, so review keys that were re-owned. Keys are revoked when their owner is deactivated or deleted. - New admin permissions (#439):
permissions:synccreatesdeploy.triggerandai.prompts.manageand grants newly created permissions to the built-inadminrole. Permissions you removed fromadminbefore stay removed. Grant them to other roles yourself — Roles & permissions. - Other new tables and columns:
api_keys.files_scope(existing keys →all),api_keys.scopes(null = no MCP access),pending_tool_calls,ai_prompts, and sort indexes oncollections_items.
Behaviour changes
- AI write tools are denied by default (#203). With
AI_WRITE_TOOLS=false(default), the assistant cannot create, update, delete, import, or roll back anything. To restore writes, setAI_WRITE_TOOLS=trueand allowlist tool classes inAI_TOOLS_WRITE_ALLOWor Settings → AI write tools; each write then asks for approval in chat — AI tool approvals & audit. - Chat keeps read actions (#439):
ManageCollections,ManageCollectionItems,ManageFiles, and the otherManage*tools still answerlist/get/searchin chat while writes are off. MCP listsManage*-backed write tools only when the write gate allows them. - MCP server (opt-in):
/mcpstays 404 untilAI_MCP_ENABLED=true. Existing API keys have nomcp:readscope, so they cannot call MCP until you grant it — MCP server. - Registration off by default (#272): the
registration_enableddefault is nowfalse. A value already saved in Settings → Auth & Security wins; installs that never saved it fall back to the new default and close self-registration. Re-enable it there if you rely on sign-up. Register, forgot-password, and reset-password are now throttled per IP (5 per minute), and the passkeys limiter is keyed on IP. - Webhook signatures and replay window (#273): outbound webhooks add
X-Externa-Signature-V2over"{timestamp}.{body}"; the legacyX-Externa-Signaturestays for one minor — move receivers to V2 (Outbound webhooks). Breaking for inbound AI collection-import callers: they must sendX-AI-Webhook-Timestamp(within 300 s), sign"{timestamp}.{body}", and never resend the same signature (replays are rejected) — AI API. - Docker workers run as
www-data(#320): Horizon, Reverb, scheduler, Pulse, and queue workers drop from root viasetpriv. Bind-mountedstorage/orbootstrap/cachevolumes created as root may need a one-timechown -R www-data:www-data storage bootstrap/cache(or a recreated volume). The entrypoint stillchowns when it starts as root. - Docker refuses placeholder DB passwords in production (#272): with
APP_ENV=production, the entrypoint exits whenDB_PASSWORDis empty,change-me,changeme,password, orsecret. - Security headers on by default (#187): admin pages now send
X-Frame-Options: DENY, a CSP,Referrer-Policy, andPermissions-Policy. If you embed the admin in an iframe, load admin assets from another origin, or serve previews from a disk without aurl, adjustSECURITY_*or setSECURITY_CSP— Security headers. - SSRF hardening (#274): remote fetches deny CGNAT
100.64.0.0/10and pin DNS; OIDC discovery and endpoints are now SSRF-checked. A lab IdP on a private or CGNAT host needsOIDC_ALLOW_PRIVATE_HOSTS=true— SSO / OIDC. APP_DEBUG=falseis the new.env.exampledefault. Existing.envfiles are not touched; keep itfalsein production.- API key
files_scope(core#322): new columnapi_keys.files_scope(none|read|all). Migration sets existing keys toall(same Files API behaviour as 1.3.0). New keys withcollection_slugsdefault tonone; unscoped keys default toall(orreadwhenread_only). After upgrade, review scoped partner keys and tightenfiles_scopewhere least privilege is needed — API abuse controls. - Upload type policy (core#327 / PR #362): Settings → Files & Media gains optional extension/MIME allowlists and an Allow SVG opt-in (off by default). Empty allowlists keep the 1.3.0
config/files.phpdefaults. The hard floor (executables, HTML, SSI.shtml, etc.) still cannot be disabled. Review SVG only if you need logos/icons and accept XSS risk when SVG is served or inlined — File manager — Upload security.
New in 1.4.0 (opt-in, no action needed): batch item writes, system-column and relational filters, sort, Live Preview as_of, deploy hooks, field conversion, GCS / Azure disks, theme presets. Env keys: Environment variables. Full list: core CHANGELOG.md.
1.2.x → 1.3.0
Upgrade steps (source)
- Checkout / merge tag
v1.3.0. composer installnpm ci && npm run buildphp artisan migrate— addsschedule_error/schedule_failed_atoncollections_items(core PR #303)- Set
AI_WEBHOOK_TOKEN(required — fail-closed in every environment) php artisan optimize:clear- Restart queue workers / scheduler / Horizon (and Reverb / Pulse if you run them)
Docker: pull ghcr.io/qiick-io/externa-core:1.3.0 (or latest). When building from a checkout, confirm .dockerignore excludes /backups, /storage/app/*, and agent tooling folders (core PR #294).
Behaviour / breaking notes
- Breaking — outbound webhooks / Slack SSRF (core#118 / PR #267): loopback, RFC1918, link-local, and other reserved hosts are rejected on save and at delivery unless you opt in. Delivery also refuses HTTP redirects. For LAN receivers (e.g. n8n on a private IP), set
WEBHOOKS_ALLOW_PRIVATE_HOSTS=trueand refresh config cache. Public HTTPS endpoints need no change — Outbound webhooks. - Breaking — upload MIME allowlist (same PR): after the extension denylist, uploads must sniff to a type in
config/files.phpupload_allowed_mimes, with magic-byte checks for claimed formats. SVG, JSON, and XML are not allowed. SSI/markup extensions (shtml,xml, …) are also denylisted (PR #314) — File manager — Upload security. - AI webhook fail-closed + rotation (core#185 / #207): empty
AI_WEBHOOK_TOKENrejects requests in every environment (no local unsigned bypass). Rotate withphp artisan ai:rotate-webhook-token(AI_WEBHOOK_TOKEN_PREVIOUS/AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT); thenconfig:clear/ restart workers — AI API. TRUSTED_PROXIESnow applied (PR #295): previously documented but unread. Set behind a reverse proxy and verify client IP / HTTPS / rate limits / API key IP allowlists — Reverse proxy.- AuthZ — only super-admins grant/manage super-admin principals (PR #307); collection grants fail closed for ACL-scoped admins (PR #312) — AuthZ map.
- Partial item updates — REST
PATCHand GraphQLupdateItemkeep omitted fields/locales (PR #293). - OpenAPI in prod images —
GET /api/v1/openapi.jsonworks withcomposer install --no-dev(PR #310;symfony/yamlis a direct require). - UI bookmarks — project settings split (core#201 / PR #304):
/settings/projectis General only. Localization, Auth & Security (OIDC), Content (Live Preview), Files & Media, API, and Integrations each have their own path. Sidebar modules moved to/settings/appearance/sidebar. Deprecated test aliases (project.webhook-test, notifier tests) remain for one minor — preferproject.integrations.*. Guide: Project settings. - Also in 1.3.0 (non-breaking defaults): optional API cost / GraphQL / payload caps (core#113), API key scopes including
?include=/ cache / Filesread_only(core#116 / PR #321), field type/translatable lock with stored values (PR #265). See coreCHANGELOG.md[1.3.0].
1.2.1 → 1.2.2
- Security patch (#250). Composer
phpconstraint is now^8.4.27(#248). No new migrations. See the PHP security notice above and discussion #247 — do not treat older 8.4.x as enough. - AI collection-import webhook: sign the raw request body only (
sha256=+ HMAC-SHA256). Invalid or missing signatures return 401 (was 403). SetAI_WEBHOOK_TOKENin every non-local environment (#214) — AI API. externa:installkeeps an existingAPP_KEYor generates one, runs the minimal profile without Redis, and can be re-run with--force(#249) — Installation.- Docker: pin
ghcr.io/qiick-io/externa-core:1.2.2.
1.2.0 → 1.2.1
- Hotfix for field renames dropping values from pending drafts and older revisions (#218, #223). No new migrations.
- Right after deploying, before the scheduler runs or anyone publishes, repair drafts/revisions that still hold a pre-rename key:
php artisan externa:repair-renamed-field-drafts(dry run), then--apply. See Schema changes. - Run
npm ci && npm run build(theviterange is now^8.3.5). - Docker: pin
ghcr.io/qiick-io/externa-core:1.2.1(or jump straight to 1.2.2 if you are catching up).
1.0.x → 1.1.0
- AI chat history schema (
laravel/ai^1.0): conversations use a polymorphic participant, and messages storesteps+statusinstead oftool_calls/tool_results. You must runphp artisan migrate(included inexterna:upgrade) before opening existing AI conversations. Back up first: Backup & restore. - Destructive AI tool actions (delete, force-delete, bulk delete, delete field, AI turn rollback) now wait for Approve / Reject in the chat.
- Scheduled publish: make sure
collections:process-schedulesruns on the scheduler (php artisan schedule:workor cron).
1.1.x → 1.2.0
- Run
composer install(addslaravel/socialite^5.31), thennpm ci && npm run build, beforeexterna:upgrade. - Migrations add editorial approval columns,
collections_items.board_sort/published_at, andusers.oidc_sub, and makeusers.passwordnullable (SSO-only users). Project settings also gain notifier mute listnotifier_event_types(null = all events). - Run
permissions:sync(included inexterna:upgrade) to createcan-submit-collections/can-approve-collections. - Restart Horizon and Reverb after deploy (presence, Meilisearch sync, and git-export jobs).
- Docker: pin
ghcr.io/qiick-io/externa-core:1.2.0. - All new integrations (Meilisearch, git export, Slack/Telegram, OIDC) are off until configured — Environment variables.
Before you start
- Backup DB +
.env+storage/— Backup & restore. - Put the site in maintenance mode if operators share the host:
php artisan down
- Note current version (
composer.json→versionor Git tag).
Path A — Git tag / upstream (recommended)
A1. You cloned qiick-io/externa-core (or keep origin = Externa)
cd /path/to/externa-core
git fetch --tags origin
git checkout v1.0.0 # example — use the target release tag
composer install --no-dev --optimize-autoloader
Then continue at Shared steps.
A2. You create-project’d, then pushed to your remote
origin is yours. Add Externa as upstream and merge the release tag:
cd /path/to/my-externa
git remote add upstream https://github.com/qiick-io/externa-core.git # once
git fetch --tags upstream
git merge v1.0.0 # example — resolve conflicts if you customized app code
composer install --no-dev --optimize-autoloader
Vanilla trees (few or no app edits) usually merge cleanly. Heavy forks: merge/rebase carefully, keep .env / storage / DB.
Or use the assisted CLI path below instead of the manual git steps.
Contributors on develop: merge/rebase onto the target tag or release branch, then the same composer / migrate / build steps.
Assisted sync (--sync-upstream)
Opt-in only. Plain externa:upgrade still does not pull from GitHub. Use this when you want the Artisan command to fetch the official remote and merge a release tag, then run the usual migrate / permissions / cache steps.
What it does
- Requires a git working tree (fails clearly if not).
- Ensures remote
upstream→https://github.com/qiick-io/externa-core.git(adds it if missing). git fetch --tags upstream, verifiesrefs/tags/vX.Y.Z.- Lists files that would change (
git diff --name-status HEAD vX.Y.Z). - Asks for confirm (“N files will be overwritten / changed — proceed with caution?”) unless
--yes. git merge --no-edit vX.Y.Z— on conflict, stops (no migrate); resolve by hand.- On success (or if you skipped sync): migrate +
permissions:sync+optimize:clear.
Risks / when to use
- May overwrite customized app files and can fail on heavy forks. Prefer Path A by hand if you have large local diffs.
- Refuses a dirty tree unless
--force(not recommended with sync). - Does not run
composer/npm— install deps after code lands. - Prefer dry-run first.
# Preview only — no merge, no migrate
php artisan externa:upgrade --sync-upstream=v1.0.0 --dry-run
# Apply sync (confirm prompt) then migrate / permissions / cache
php artisan externa:upgrade --sync-upstream=v1.0.0
# Non-interactive apply (CI / scripts) — still fails on conflicts
php artisan externa:upgrade --sync-upstream=v1.0.0 --yes
# Production hosts
php artisan externa:upgrade --sync-upstream=v1.0.0 --force --yes
Warning
Assisted sync is a convenience for mostly-vanilla trees. Customized forks should merge carefully, keep .env, and never blind-commit conflict resolutions.
AI-assisted upgrade
When merge conflicts or a large diff make a human review painful, paste context into your coding agent. Keep .env and secrets out of commits. Do not ask the agent to force-push or blind-commit without review.
Template prompt (per version)
Copy and fill vOLD / vNEW (example: v1.0.0 → v1.1.0):
I am upgrading Externa (Laravel app from qiick-io/externa-core) from vOLD to vNEW.
Context to use (paste one or more):
1) GitHub compare: https://github.com/qiick-io/externa-core/compare/vOLD...vNEW
2) And/or CHANGELOG + release notes for vNEW
3) My conflicted / customized files (paste `git status` and the conflict hunks)
Goals:
- Resolve conflicts preserving my intentional customizations
- Keep .env, storage/, and local secrets untouched — never commit them
- Prefer upstream for framework/app defaults I did not customize
- After a clean tree: composer install, npm ci && npm run build, then
`php artisan externa:upgrade` (or migrate + permissions:sync + optimize:clear)
Constraints:
- Do not blind-commit; show the plan and diffs first
- Do not rewrite git history or force-push
- Call out breaking changes from the compare/CHANGELOG
After the agent proposes a resolution: review, run tests / smoke checklist, then commit yourself.
Path B — Replace tree (vanilla create-project, no useful git history)
When you never put the site under git, or history is disposable:
- Backup
.env,storage/, and the database. composer create-project qiick/externa-core:X.Y.Z /tmp/externa-new(pin the target version).- Copy
.env(and any local customizations) into the new tree; keep the same DB and storage volume/mount. - Run Shared steps in the new tree, then cut over the web root.
Do not expect composer update qiick/externa-core to refresh application source — the package was copied once at create-project time.
Path C — Docker (GHCR / Compose)
Keep named volumes (externa_*_storage, DB volumes). Bump the image tag, then bring the stack back up:
# Quick deploy sample — pin the release you want (example: 1.2.2)
EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.2.2 \
docker compose -f compose.quick.yaml pull
EXTERNA_IMAGE=ghcr.io/qiick-io/externa-core:1.2.2 \
docker compose -f compose.quick.yaml up -d
Build-from-source Compose: checkout / merge the matching tag in the repo that owns compose.yaml, then docker compose … up --build -d.
If RUN_MIGRATIONS is off, run migrate (+ permissions) in the app container:
docker compose exec app php artisan migrate --force
docker compose exec app php artisan permissions:sync
# or: docker compose exec app php artisan externa:upgrade --force
Details: Deploy with Docker · volumes note below.
Shared steps (after code is on the new release)
php artisan migrate --force
php artisan permissions:sync
# Only if PermissionEnum removed names and you intend cleanup:
# php artisan permissions:sync --prune
npm ci
npm run build
php artisan optimize:clear
# production hosts that cache config/routes:
# php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan up
Restart queue workers / Horizon / Reverb / pulse:work / scheduler after deploy so they load new code.
php artisan externa:upgrade
Runs the safe automated subset by default: migrate --force, permissions:sync, optimize:clear.
php artisan externa:upgrade
# production or dirty git tree:
php artisan externa:upgrade --force
Does not (by default) fetch git tags, pull from GitHub, run composer, or run npm. Get the new code first (Path A/B/C), or use opt-in --sync-upstream above. Refuses APP_ENV=production and a dirty git working tree unless --force.
| Flag | Meaning |
|---|---|
--sync-upstream=vX.Y.Z | Opt-in: fetch upstream + merge tag (see Assisted sync) |
--dry-run | With --sync-upstream: list files only; no merge / migrate |
--yes | Skip confirm when applying --sync-upstream |
--force | Allow production and/or dirty tree |
--no-migrate / --no-permissions / --prune-permissions | Tune the post-sync subset |
Docker volumes
With Compose (compose.yaml / compose.prod.yaml / compose.quick.yaml):
- Keep named volumes (
externa_storage/externa_prod_storage/externa_quick_*, DB volumes). - Rebuild or pull the app image after the tag change.
- Prefer
externa:upgrade --force(or migrate +permissions:sync) inside the app container when entrypoint migrate is off.
Smoke checklist
- [ ]
GET /health/ready→ 200 - [ ] Login as an operator
- [ ] Open one collection + one item
- [ ] Queue still processes (zip or a trivial job) if you rely on async features
Non-goals
- Silent auto-pull as the default of
externa:upgrade(opt-in--sync-upstreamonly) - Remote zip overwrite without git
- Composer create-project auto-update magic
- Cross-engine DB moves (SQLite → Postgres) — dump/restore with engine tools separately