Audit a site's gadya/cms install, upgrade it to the latest release, and switch on and wire up every feature it has not taken up yet - config, migrations, plugin switches, schedule, templates - then hand over the steps only a person can do.
php artisan boost:add-skill gadya/cms
Save this content to: .claude/skills/gadya-cms-upgrade/SKILL.md
# Gadya CMS Upgrade
## When to use this skill
Use it when asked to update or upgrade gadya/cms, to audit a site's install, to "turn on everything", or to bring a client site up to the latest version. It works the same on every site: `gadya-cms:audit` says what is missing, and you work down its list until nothing is left to do.
Documentation for the installed version is in `vendor/gadya/cms/docs/`. Read the file named in each step before you change anything it covers. After the update, `vendor/gadya/cms/docs/` is the new version's documentation.
## Rules
- **Code and config only, never content.** Pages, menus, photos and articles live in the database and belong to the client. Never run `migrate:fresh`, `db:seed`, `gadya-cms:import*` or `gadya-cms:install --force`, and never write to `gadyacms_*` tables. When a feature needs content (a menu link to `/events`, a first event), list it for the person to do in the panel.
- **Branch first, never push.** Start from a clean tree (`git status --short` is empty). Pushing often deploys; leave that to the person.
- **Never move or delete a git tag**, in the app or the package.
- **Business facts come from the site, not the package.** The package config's examples (Springfield, 555 numbers, example.com) are placeholders. Take names, phones, domains and addresses from `config/site.php`, the existing layout, `.env` and the published document. Ask only when none of those has the answer.
- **Some things stay off unless the person asks:**
- comments (`blog.comments.enabled`), because someone must moderate them
- coming-soon mode
- `->brand(false)` or `->analytics(false)` on a site that chose them deliberately. The audit marks them; ask.
- **API keys are the client's.** AI and Search Console keys are entered in the panel and stored encrypted, so never put them in config or `.env`.
- Follow the application's own rules in `.ai/rules` and its CLAUDE.md or AGENTS.md. Run Pint on PHP you touch.
## 1. Audit where the site is
```bash
git status --short # must be empty
git switch -c chore/gadya-cms-upgrade
composer show gadya/cms | grep versions # installed
composer show gadya/cms --latest --format=json | php -r 'echo json_decode(stream_get_contents(STDIN))->latest, PHP_EOL;'
grep '"gadya/cms"' composer.json # the constraint
php artisan gadya-cms:audit # from 0.4.5; skip on older installs
php artisan test --compact # the baseline: note anything already failing
```
Write down the installed version, the latest version, the constraint and every failing test before you start.
## 2. Update the package
On 0.x a minor release may break things, so `^0.4` never reaches 0.5:
- **Same minor (0.4.3 → 0.4.9):** `composer update gadya/cms -W`
- **New minor (0.4 → 0.5):** `composer require gadya/cms:^0.5 -W`
If `gadya/connect` is older than 0.6 (`php artisan help gadya:upgrade` fails), update it too: `composer update gadya/connect -W`.
Then let the packages do the mechanical part. Each step checks whether it still has anything to do, so this is right after every release:
```bash
php artisan gadya:upgrade --phase=code # the release's changes to the repository, then boost:update --discover
php artisan gadya:upgrade # migrate, the release's server steps, optimize:clear, filament:assets
```
Read what each prints. A `failed` step must be fixed before you go on; `cms.workflow-template` only reports - never edit `.github/workflows/gadya-update.yml` from its message unless the person asks; the portal refreshes it. On a connect without `gadya:upgrade`, run `php artisan migrate`, `php artisan filament:assets` and `php artisan optimize:clear` instead.
## 3. Read what changed
Read `vendor/gadya/cms/CHANGELOG.md` from the old version up to the new one. Also read every section of `vendor/gadya/cms/docs/upgrading.md` between them. These are the breaking changes and the notes on what a person must do; apply each one. The published `config/gadya-cms.php` is merged over the package's defaults at every depth, so a key a release adds works without being copied. Lists (`locales.enabled`) and the site-owned maps (`editable_fields`, `globals`, `users.roles`, `forms.forms`, `pages.types`, `pages.content_fields`, `navigation.menus`, `fonts.*`, `menus.dietary`, `seo.content_signals`) are the site's whole answer, though: when a release adds an entry the site should have (a new editable path, a new role ability), add it there by hand.
## 4. Refresh the skills
`gadya:upgrade --phase=code` ran `php artisan boost:update --discover` where Boost is set up (run it yourself on an older connect). `--discover` picks up skills a release added, including this one on a site that never had it. Check that `boost.json` then lists `gadya-cms-content`, `gadya-cms-development`, `gadya-cms-forms` and `gadya-cms-upgrade` under `skills`.
## 5. Work down the audit
```bash
php artisan gadya-cms:audit --json
```
Each check has a `group`, `label`, `status` (`ok`, `todo`, `optional`) and a `fix`. Clear every `todo` and decide every `optional`, group by group. Features a site has switched off, and a static robots.txt or sitemap.xml, show as `optional`: turn them on when asked to "turn everything on", otherwise leave them and list them.
### Config
- The "Every key the package reads" finding is optional: those keys already take the package's defaults. Copy one from `vendor/gadya/cms/config/gadya-cms.php` into `config/gadya-cms.php` (same place, the package's comment above it) only when this site needs a different value, or when asked to "turn everything on" for a switch that is off by default.
- Fill `seo.site_name`, `seo.organization` (telephone, email, address, area, `same_as` social links) and `seo.domains` (every domain the business owns, the live one first; `dig NS domain` tells you if one is parked elsewhere). Also fill `seo.content_signals`.
- Add every new editable path to `editable_fields`. The live editor refuses anything not listed.
### Database
- `gadya:upgrade` ran the migrations. If the audit still lists pending ones, run `php artisan migrate` and find out why.
### Features
Each feature switch, and what else turning it on needs:
| Switch | Turn on | Also build | Read |
| --- | --- | --- | --- |
| `->blog()` / `blog.routes` | remove `->blog(false)`; `blog.routes => true` | `blog.layout` is the site's public layout; `blog.prefix`; add `blog` to `pages.reserved_slugs` and `pages.route_excluded_slugs` | `articles-and-ai.md` |
| `->events()` / `events.routes` | `events.routes => true` | `events.heading` and `events.description` in the site's voice; `events` in both slug lists | `events-and-search.md` |
| `->search()` / `site_search.routes` | `site_search.routes => true` | `@cmsSearchForm` in the header or footer, styled like the site; `search` in both slug lists; `site_search` in `analytics.events` | `events-and-search.md` |
| `->newsletter()` / `newsletter.enabled` | `newsletter.enabled => true` | `@cmsNewsletterForm` in the footer, wording in `newsletter.*` | `events-and-search.md` |
| `->forms()` | on by default | every enquiry form posts through `@cmsForm('key')` plus `@cmsFormStatus('key')`, with the form defined under `forms.forms` | `forms.md` |
| `->analytics()` / `analytics.enabled` | on | `data-analytics="…"` on the site's key buttons (booking, directions, CTA), with each name listed in `analytics.events` | `analytics.md` |
| `->ai()` | on | nothing in code: the client enters the key under Settings → AI | `articles-and-ai.md` |
| `->foodMenus()` | only on a restaurant, café or bakery | `<x-gadya-cms::menu menu="…" />` on the menu page in place of a hand-written menu (list the menus for the client to enter in the panel); `<x-gadya-cms::menu-specials />` where specials belong | `local-business.md` |
| Opening hours | always on | `<x-gadya-cms::opening-hours />`, `<x-gadya-cms::open-status />`, `<x-gadya-cms::todays-hours />` where the site shows hours, replacing hand-written ones; check `hours.timezone`; list "enter the hours under Appearance → Opening hours and publish" for the client | `local-business.md` |
| Privacy choices / `privacy.banner_enabled` | only when the audit finds third-party trackers, or the client asks | wrap each tracker in `<x-gadya-cms::consented-script category="analytics|marketing">`; `<x-gadya-cms::consent-banner />` before `</body>`; `<x-gadya-cms::privacy-choices-link />` in the footer; list "switch on under Settings → Privacy choices and publish" for the person | `privacy.md` |
| `->redirects()`, `->team()`, `->profile()`, `->unsavedChangesAlerts()`, `->brand()` | on | nothing | `roles-and-globals.md`, `media-and-team.md` |
| `seo.sitemap`, `seo.robots`, `seo.llms`, `seo.markdown`, `seo.link_headers` | `true` | delete any static `public/robots.txt` or `public/sitemap.xml` | `seo.md`, `search-and-readiness.md` |
| `blog.comments.enabled` | only if asked | `blog.comments.notify` addresses | `articles-and-ai.md` |
A site with its own catch-all page route (`/{slug}`) must list every new top-level prefix in `pages.route_excluded_slugs`, or the page route swallows it. Check with `php artisan route:list`.
### Schedule
Add each missing line from the audit to `routes/console.php`. They are idempotent, so schedule all of them even before Search Console is connected. The server needs its cron running `php artisan schedule:run` every minute; put that in the hand-over.
### Templates
- The public layout has `@cmsSeo($page)` in `<head>`, replacing any hand-written title, description, canonical or Open Graph tags. It also has `@cmsToolbar` just before `</body>`.
- `@gadyaBuiltBy` is the last thing in the footer. Replace any hand-pasted `<gadya-built-by>` script and tag with it. If the footer's background is dark, set `built_by.color` to the footer's text colour.
- Search and newsletter forms go where a visitor looks for them. Match the site's own markup and CSS rather than leaving them unstyled.
- Hard-coded text in templates that the client should be able to change becomes `@editable(...)` against the document, with the path in `editable_fields`. Do this only where the template already reads the document for that element; do not invent new content.
### Install
- Run `php artisan filament:assets` whenever the stylesheet check fails (the server phase of `gadya:upgrade` does it too).
- Missing gates: define `manage-content` and `manage-users` as `docs/installation.md` shows.
### Forms
Clients can now build their own forms under **Content → Forms** and place them on any page. Make sure the section loop has `@cmsSection($section, $index)` (the `cms.form-sections` code step adds it to an ordinary loop; the audit names any it could not). Converting the site's existing forms is a separate job with its own skill, `gadya-cms-forms`: offer it, do not do it as part of an upgrade unless asked.
## 6. Verify
```bash
php artisan gadya-cms:audit # no "!" left; every "○" decided
vendor/bin/pint --dirty --format agent
php artisan test --compact # nothing that passed before may fail now
php artisan gadya-cms:agent-ready # note the score for the hand-over
php artisan route:list --path=events # and blog, search, sitemap.xml, robots.txt, llms.txt
```
Request each public address the upgrade touched through the app (`/`, `/sitemap.xml`, `/robots.txt`, `/llms.txt`, `/blog`, `/events`, `/search?q=a`, `/events.ics`) with `get-absolute-url` and `curl -s -o /dev/null -w '%{http_code}'`. Each must answer 200. If `/robots.txt` answers 404 locally with the right text, the web server swallowed it (see *Get found* in `search-and-readiness.md`); that is a server fix for the hand-over, not a code change.
When a test fails because of a change the release intended (a renamed heading, a new default), update the test to match. Never delete a test to make the suite pass. Anything else that fails is a regression: fix it or report it.
## 7. Commit and hand over
Commit on the branch: `chore: gadya/cms <old> → <new> - <what was switched on>`. Do not push.
Then tell the person, in this order:
1. **Versions:** the old and new versions, and the changelog highlights that matter to this client.
2. **Done:** what you switched on and wired up, and the audit before and after (to-do counts).
3. **Their decisions:** any optional item you left off, and why.
4. **After deploying,** things only a person can do:
- After deploying, run `php artisan gadya:upgrade` on the server (on a site connected to the portal, a rollout sends `upgrade.finish`, which does the same). Otherwise make sure the deploy script runs `php artisan migrate --force` and `php artisan filament:assets`.
- Make sure the cron runs `schedule:run` every minute.
- **Pair the site with the Gadya portal** if the audit says it is not connected. Use the portal MCP's `connect-site-tool`, or ask the person for a code from **Sites → Connect a site**. Then run `php artisan gadya:connect <code>` on the live server after deploying.
- Laravel Forge: delete the `location = /robots.txt` line in the site's nginx config if Settings → Get found says robots.txt answers 404.
- DNS and Search Console: follow **Settings → Get found** for each domain. It lists the exact records, and the sitemap address to submit.
- Keys: AI under **Settings → AI**; Google under **Settings → Search & speed**.
- Content: add new sections to the menu (**Appearance → Menu**) and publish, e.g. What's on or the blog. Write missing page descriptions (the readiness score lists them).
Use it when asked to update or upgrade gadya/cms, to audit a site's install, to "turn on everything", or to bring a client site up to the latest version. It works the same on every site: gadya-cms:audit says what is missing, and you work down its list until nothing is left to do.
Documentation for the installed version is in vendor/gadya/cms/docs/. Read the file named in each step before you change anything it covers. After the update, vendor/gadya/cms/docs/ is the new version's documentation.
migrate:fresh, db:seed, gadya-cms:import* or gadya-cms:install --force, and never write to gadyacms_* tables. When a feature needs content (a menu link to /events, a first event), list it for the person to do in the panel.git status --short is empty). Pushing often deploys; leave that to the person.config/site.php, the existing layout, .env and the published document. Ask only when none of those has the answer.blog.comments.enabled), because someone must moderate them->brand(false) or ->analytics(false) on a site that chose them deliberately. The audit marks them; ask..env..ai/rules and its CLAUDE.md or AGENTS.md. Run Pint on PHP you touch.git status --short # must be empty
git switch -c chore/gadya-cms-upgrade
composer show gadya/cms | grep versions # installed
composer show gadya/cms --latest --format=json | php -r 'echo json_decode(stream_get_contents(STDIN))->latest, PHP_EOL;'
grep '"gadya/cms"' composer.json # the constraint
php artisan gadya-cms:audit # from 0.4.5; skip on older installs
php artisan test --compact # the baseline: note anything already failing
Write down the installed version, the latest version, the constraint and every failing test before you start.
On 0.x a minor release may break things, so ^0.4 never reaches 0.5:
composer update gadya/cms -Wcomposer require gadya/cms:^0.5 -WIf gadya/connect is older than 0.6 (php artisan help gadya:upgrade fails), update it too: composer update gadya/connect -W.
Then let the packages do the mechanical part. Each step checks whether it still has anything to do, so this is right after every release:
php artisan gadya:upgrade --phase=code # the release's changes to the repository, then boost:update --discover
php artisan gadya:upgrade # migrate, the release's server steps, optimize:clear, filament:assets
Read what each prints. A failed step must be fixed before you go on; cms.workflow-template only reports - never edit .github/workflows/gadya-update.yml from its message unless the person asks; the portal refreshes it. On a connect without gadya:upgrade, run php artisan migrate, php artisan filament:assets and php artisan optimize:clear instead.
Read vendor/gadya/cms/CHANGELOG.md from the old version up to the new one. Also read every section of vendor/gadya/cms/docs/upgrading.md between them. These are the breaking changes and the notes on what a person must do; apply each one. The published config/gadya-cms.php is merged over the package's defaults at every depth, so a key a release adds works without being copied. Lists (locales.enabled) and the site-owned maps (editable_fields, globals, users.roles, forms.forms, pages.types, pages.content_fields, navigation.menus, fonts.*, menus.dietary, seo.content_signals) are the site's whole answer, though: when a release adds an entry the site should have (a new editable path, a new role ability), add it there by hand.
gadya:upgrade --phase=code ran php artisan boost:update --discover where Boost is set up (run it yourself on an older connect). --discover picks up skills a release added, including this one on a site that never had it. Check that boost.json then lists gadya-cms-content, gadya-cms-development, gadya-cms-forms and gadya-cms-upgrade under skills.
php artisan gadya-cms:audit --json
Each check has a group, label, status (ok, todo, optional) and a fix. Clear every todo and decide every optional, group by group. Features a site has switched off, and a static robots.txt or sitemap.xml, show as optional: turn them on when asked to "turn everything on", otherwise leave them and list them.
vendor/gadya/cms/config/gadya-cms.php into config/gadya-cms.php (same place, the package's comment above it) only when this site needs a different value, or when asked to "turn everything on" for a switch that is off by default.seo.site_name, seo.organization (telephone, email, address, area, same_as social links) and seo.domains (every domain the business owns, the live one first; dig NS domain tells you if one is parked elsewhere). Also fill seo.content_signals.editable_fields. The live editor refuses anything not listed.gadya:upgrade ran the migrations. If the audit still lists pending ones, run php artisan migrate and find out why.Each feature switch, and what else turning it on needs:
| Switch | Turn on | Also build | Read |
|---|---|---|---|
->blog() / blog.routes |
remove ->blog(false); blog.routes => true |
blog.layout is the site's public layout; blog.prefix; add blog to pages.reserved_slugs and pages.route_excluded_slugs |
articles-and-ai.md |
->events() / events.routes |
events.routes => true |
events.heading and events.description in the site's voice; events in both slug lists |
events-and-search.md |
->search() / site_search.routes |
site_search.routes => true |
@cmsSearchForm in the header or footer, styled like the site; search in both slug lists; site_search in analytics.events |
events-and-search.md |
->newsletter() / newsletter.enabled |
newsletter.enabled => true |
@cmsNewsletterForm in the footer, wording in newsletter.* |
events-and-search.md |
->forms() |
on by default | every enquiry form posts through @cmsForm('key') plus @cmsFormStatus('key'), with the form defined under forms.forms |
forms.md |
->analytics() / analytics.enabled |
on | data-analytics="…" on the site's key buttons (booking, directions, CTA), with each name listed in analytics.events |
analytics.md |
->ai() |
on | nothing in code: the client enters the key under Settings → AI | articles-and-ai.md |
->foodMenus() |
only on a restaurant, café or bakery | <x-gadya-cms::menu menu="…" /> on the menu page in place of a hand-written menu (list the menus for the client to enter in the panel); <x-gadya-cms::menu-specials /> where specials belong |
local-business.md |
| Opening hours | always on | <x-gadya-cms::opening-hours />, <x-gadya-cms::open-status />, <x-gadya-cms::todays-hours /> where the site shows hours, replacing hand-written ones; check hours.timezone; list "enter the hours under Appearance → Opening hours and publish" for the client |
local-business.md |
Privacy choices / privacy.banner_enabled |
only when the audit finds third-party trackers, or the client asks | wrap each tracker in `<x-gadya-cms::consented-script category="analytics | marketing">; <x-gadya-cms::consent-banner />before; <x-gadya-cms::privacy-choices-link />` in the footer; list "switch on under Settings → Privacy choices and publish" for the person |
->redirects(), ->team(), ->profile(), ->unsavedChangesAlerts(), ->brand() |
on | nothing | roles-and-globals.md, media-and-team.md |
seo.sitemap, seo.robots, seo.llms, seo.markdown, seo.link_headers |
true |
delete any static public/robots.txt or public/sitemap.xml |
seo.md, search-and-readiness.md |
blog.comments.enabled |
only if asked | blog.comments.notify addresses |
articles-and-ai.md |
A site with its own catch-all page route (/{slug}) must list every new top-level prefix in pages.route_excluded_slugs, or the page route swallows it. Check with php artisan route:list.
Add each missing line from the audit to routes/console.php. They are idempotent, so schedule all of them even before Search Console is connected. The server needs its cron running php artisan schedule:run every minute; put that in the hand-over.
@cmsSeo($page) in <head>, replacing any hand-written title, description, canonical or Open Graph tags. It also has @cmsToolbar just before </body>.@gadyaBuiltBy is the last thing in the footer. Replace any hand-pasted <gadya-built-by> script and tag with it. If the footer's background is dark, set built_by.color to the footer's text colour.@editable(...) against the document, with the path in editable_fields. Do this only where the template already reads the document for that element; do not invent new content.php artisan filament:assets whenever the stylesheet check fails (the server phase of gadya:upgrade does it too).manage-content and manage-users as docs/installation.md shows.Clients can now build their own forms under Content → Forms and place them on any page. Make sure the section loop has @cmsSection($section, $index) (the cms.form-sections code step adds it to an ordinary loop; the audit names any it could not). Converting the site's existing forms is a separate job with its own skill, gadya-cms-forms: offer it, do not do it as part of an upgrade unless asked.
php artisan gadya-cms:audit # no "!" left; every "○" decided
vendor/bin/pint --dirty --format agent
php artisan test --compact # nothing that passed before may fail now
php artisan gadya-cms:agent-ready # note the score for the hand-over
php artisan route:list --path=events # and blog, search, sitemap.xml, robots.txt, llms.txt
Request each public address the upgrade touched through the app (/, /sitemap.xml, /robots.txt, /llms.txt, /blog, /events, /search?q=a, /events.ics) with get-absolute-url and curl -s -o /dev/null -w '%{http_code}'. Each must answer 200. If /robots.txt answers 404 locally with the right text, the web server swallowed it (see Get found in search-and-readiness.md); that is a server fix for the hand-over, not a code change.
When a test fails because of a change the release intended (a renamed heading, a new default), update the test to match. Never delete a test to make the suite pass. Anything else that fails is a regression: fix it or report it.
Commit on the branch: chore: gadya/cms <old> → <new> - <what was switched on>. Do not push.
Then tell the person, in this order:
php artisan gadya:upgrade on the server (on a site connected to the portal, a rollout sends upgrade.finish, which does the same). Otherwise make sure the deploy script runs php artisan migrate --force and php artisan filament:assets.schedule:run every minute.connect-site-tool, or ask the person for a code from Sites → Connect a site. Then run php artisan gadya:connect <code> on the live server after deploying.location = /robots.txt line in the site's nginx config if Settings → Get found says robots.txt answers 404.How can I help you explore Laravel packages today?