---
url: /getting-started/upgrading.md
---
# Upgrading to Evolve v2.0

Evolve v2.0 is a major release that combines three changes:

1. **Monorepo**: All 11 packages consolidated into a single `racerfish/evolve` package
2. **Livewire 3**: Full migration from Livewire 2 to 3
3. **Laravel 12**: Framework upgrade from Laravel 10 to 12

## What Changed

| Before (v1.x) | After (v2.0) |
|----------------|--------------|
| 11 separate Composer packages | 1 unified `racerfish/evolve` package |
| Livewire 2 (`wire:model.defer`, `$this->emit()`) | Livewire 3 (`wire:model`, `$this->dispatch()`) |
| Laravel 10 | Laravel 12 |
| Carbon 2 (`formatLocalized()`) | Carbon 3 (`isoFormat()`) |
| Multiple VCS repositories | 1 VCS repository (`racerfish/evolve-cms`) |

::: tip Read the pitfalls section first
Every step below is necessary but not sufficient. The failures that actually
cost time on client sites are collected in
[Pitfalls Found in Real Upgrades](#pitfalls-found-in-real-upgrades): a dead
backend from stale Livewire 2 assets, a deploy aborted by `view:cache`, two
Alpine instances, an unresolvable image driver. They all survive a clean
`composer update` and surface later. Skim that section before you start and
work through it before you call the upgrade done.
:::

::: warning Laravel 12 is required
All of Evolve v2.x requires Laravel 12. Skip Laravel 11 during the upgrade. If you land on Laravel 11, Composer will silently resolve `racerfish/evolve:^2.0` to **2.0.2** because 2.1.0+ pulls in `laravel/ai ^0.4` which requires Laravel 12. The v2.0.x line also has a known UI bug (an Alpine 2 vs. Livewire 3 `@entangle` version skew) that breaks search inputs and resource filters in the backend. Going directly to Laravel 12 avoids both issues.

If your `composer update` stops at 2.0.x, confirm with `composer why-not racerfish/evolve 2.1.1`. A `laravel/framework` entry in the output means the framework upgrade is still pending.
:::

## Prerequisites

* PHP 8.3 or higher
* MySQL 8.0+ or PostgreSQL 13+
* Back up your database before upgrading
* Ensure all pending migrations have been run: `php artisan migrate:status`
* Review any published Livewire components in `app/Livewire/` or `app/Http/Livewire/` for LW2 patterns

## Step 1: Update Composer Repositories

Replace any individual VCS repositories in your `composer.json` with the single monorepo:

```json
"repositories": [
    {
        "type": "vcs",
        "url": "git@github.com:racerfish/evolve-cms.git"
    }
]
```

Remove all old repository entries pointing to `racerfish/evolve-core`, `racerfish/evolve-blog`, `racerfish/evolve-theme`, etc.

## Step 2: Swap Packages

Remove all individual packages and require the unified package:

```sh
composer remove \
    racerfish/blog \
    racerfish/company \
    racerfish/devtools \
    racerfish/event \
    racerfish/faq \
    racerfish/locator \
    racerfish/project \
    racerfish/scout24 \
    racerfish/shopify \
    racerfish/theme

composer require racerfish/evolve:^2.0
```

::: tip
You only need to remove the packages that your instance currently has installed. If you don't use `racerfish/scout24`, skip removing it.
:::

::: info
The `^2.0` caret constraint resolves to `>=2.0.0 <3.0.0`, so future minor releases (2.1, 2.2, etc.) and patch updates are picked up automatically when you run `composer update racerfish/evolve`. The version is not pinned to 2.0.
:::

## Step 3: Update Laravel Framework

Update your `composer.json` to require Laravel 12 and its compatible dependencies:

```json
"require": {
    "php": "^8.3.2",
    "laravel/framework": "^12.0",
    "racerfish/evolve": "^2.0"
}
```

Also update any third-party packages that don't support Laravel 12. Common ones:

| Package | v1.x (L10) | v2.x (L12) |
|---------|------------|------------|
| `sentry/sentry-laravel` | `^3.4` | `^4.0` |
| `nunomaduro/collision` | `^7.0` | `^8.0` |
| `larastan/larastan` | `nunomaduro/larastan ^2.2` | `^3.0` |
| `spatie/laravel-ignition` | `^2.0` | `^2.0` (compatible) |

For example, if your project uses Sentry:

```json
"require": {
    "sentry/sentry-laravel": "^4.0"
}
```

And in `require-dev`:

```json
"require-dev": {
    "nunomaduro/collision": "^8.0",
    "larastan/larastan": "^3.0"
}
```

::: warning
Run `composer why-not laravel/framework 12.0` to identify any packages blocking the upgrade. The error message will show which package constrains `illuminate/support` to L11 or earlier. Apply the same check for `composer why-not racerfish/evolve 2.1.1` to confirm the framework is the only blocker.
:::

## Step 4: Remove the Livewire Assets Publish Script

In your `composer.json`, remove the `livewire:assets` publish line from `post-autoload-dump`. Livewire 3 handles assets automatically.

```json
"scripts": {
    "post-autoload-dump": [
        "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
        "@php artisan package:discover --ansi"
    ]
}
```

::: danger Removing the line is not enough
It stops the directory being recreated. It does not delete what is already
there, and a leftover `public/vendor/livewire/` silently kills every
`wire:click` in the backend. Delete it now:

```sh
rm -rf public/vendor/livewire
```

See [Delete the Livewire 2 published assets](#delete-the-livewire-2-published-assets).
:::

## Step 5: Update `dont-discover`

Simplify the `dont-discover` list. The monorepo only needs one entry:

```json
"extra": {
    "laravel": {
        "dont-discover": [
            "racerfish/evolve"
        ]
    }
}
```

## Step 6: Add Livewire 3 Configuration

Add `legacy_model_binding` to `config/livewire.php`:

```php
// config/livewire.php
return [
    'legacy_model_binding' => true,

    'temporary_file_upload' => [
        'rules' => 'file|max:50000',
    ],
];
```

::: warning
The `legacy_model_binding` setting is required for Evolve's `wire:model="item.property"` pattern to work in Livewire 3. Do not remove this setting.
:::

::: danger This file replaces whole arrays
Livewire merges your config over its own with a shallow `array_merge`, so the
partial `temporary_file_upload` block above deletes the keys it does not repeat
and leaves uploads misconfigured. Copy the full block from
[`config/livewire.php` overrides whole arrays, not single keys](#config-livewire-php-overrides-whole-arrays-not-single-keys).
:::

## Step 7: Migrate Frontend Template (Livewire Directives)

Livewire 3 removed the `@livewireStyles` and `@livewireScripts` Blade directives. If your theme layout uses these, you will get:

```
Call to undefined method Livewire\LivewireManager::scripts()
```

### Fix for frontend layouts

In your theme's main layout (typically `theme/views/frontend.blade.php` or similar), replace the old Blade directives with direct calls to the `FrontendAssets` class:

```blade
{{-- Before (LW2) --}}
<head>
    ...
    @livewireStyles
</head>
<body>
    ...
    @livewireScripts
</body>

{{-- After (LW3) --}}
<head>
    ...
    {!! \Livewire\Mechanisms\FrontendAssets\FrontendAssets::styles() !!}
</head>
<body>
    ...
    {!! \Livewire\Mechanisms\FrontendAssets\FrontendAssets::scripts() !!}
</body>
```

::: warning
Do NOT use `@livewireScriptConfig` as a replacement. That directive only outputs the config JSON and suppresses auto-injection of the actual `livewire.js` script, which means Alpine.js (bundled with Livewire 3) will never load. Use `FrontendAssets::scripts()` which includes both the JS file and the config.
:::

::: tip
For **frontend theme layouts** that only occasionally render Livewire components (e.g., contact forms), you can alternatively rely on auto-injection: remove all Livewire directives entirely, and Livewire 3 will auto-inject its assets into any response with `</html>` that renders a Livewire component. However, explicit inclusion via `FrontendAssets` is more reliable.
:::

### Sites with Cookiebot / cookie consent managers

If your theme previously used the cookieconsent pattern to prevent consent managers from blocking Livewire:

```blade
{{-- Before (LW2) --}}
{!! str_replace('<script', '<script data-cookieconsent="ignore"', \Livewire\Livewire::scripts()) !!}
```

This no longer works because `Livewire::scripts()` was removed from the facade. In LW3, use the static method on the `FrontendAssets` class and disable auto-injection:

```php
// config/livewire.php
return [
    'inject_assets' => false,  // disable auto-injection so we control the output
    'legacy_model_binding' => true,
    // ...
];
```

```blade
{{-- After (LW3) --}}
{!! str_replace('<script', '<script data-cookieconsent="ignore"', \Livewire\Mechanisms\FrontendAssets\FrontendAssets::styles()) !!}
{!! str_replace('<script', '<script data-cookieconsent="ignore"', \Livewire\Mechanisms\FrontendAssets\FrontendAssets::scripts()) !!}
```

Place styles in `<head>` and scripts before `</body>` (same positions as before). When `inject_assets` is `false`, you are fully responsible for including both styles and scripts.

::: warning
When using `inject_assets => false`, the `@livewireScriptConfig` directive is not needed. The `FrontendAssets::scripts()` call already includes the config. Do not use both.
:::

### Published backend views

If you have published evolve backend views (in `theme/views/vendor/evolve/`), check for `@livewireStyles` and `@livewireScripts` in `app.blade.php` and `blank.blade.php` and apply the same replacement to `@livewireScriptConfig`.

## Step 8: Publish the Module Configuration

Add the `modules` array to your `config/evolve.php` to control which modules are loaded:

```php
// config/evolve.php
return [
    'modules' => [
        'blog'     => true,
        'company'  => true,
        'devtools' => true,
        'event'    => true,
        'faq'      => true,
        'locator'  => true,
        'project'  => true,
        'scout24'  => true,
        'shopify'  => true,
    ],

    // ... rest of your existing config
];
```

Set any unused modules to `false` to prevent loading their routes, migrations, and views.

## Step 9: Migrate Published Livewire Components

If you have published Livewire components in `app/Http/Livewire/` or `app/Livewire/`, update them for Livewire 3:

### Namespace change

```php
// Before
namespace App\Http\Livewire\Blocks\Blog;

// After
namespace App\Livewire\Blocks\Blog;
```

Move the files from `app/Http/Livewire/` to `app/Livewire/` accordingly.

### Wire model syntax

```blade
{{-- Before (LW2) --}}
<input wire:model.defer="search" />

{{-- After (LW3) --}}
<input wire:model="search" />
```

In Livewire 3, `wire:model` is deferred by default. Use `wire:model.live` for instant reactivity.

### Event dispatch

```php
// Before (LW2)
$this->emit('eventName', $data);
$this->emitUp('eventName', $data);

// After (LW3)
$this->dispatch('eventName', data: $data);
```

All dispatch calls must use **named parameters**.

### Form submit

```blade
{{-- Before (LW2) --}}
<form wire:submit.prevent="save">

{{-- After (LW3) --}}
<form wire:submit="save">
```

### Computed properties

```php
// Before (LW2)
public function getItemsProperty() { ... }

// After (LW3)
use Livewire\Attributes\Computed;

#[Computed]
public function items() { ... }
```

### Query string

```php
// Before (LW2)
protected $queryString = ['search' => ['except' => '']];

// After (LW3)
use Livewire\Attributes\Url;

#[Url(except: '')]
public string $search = '';
```

### Entangle

```blade
{{-- Before (LW2) --}}
query: @entangle('search').defer,

{{-- After (LW3) --}}
query: @entangle('search'),
```

In Livewire 3, `@entangle` is deferred by default. Use `@entangle('search').live` for instant sync.

## Step 10: Update Date Formatting (Carbon 3)

Laravel 12 ships with Carbon 3, which removed `formatLocalized()`. Replace with `isoFormat()`:

```php
// Before
$date->formatLocalized('%d. %B %Y');

// After
$date->isoFormat('DD. MMMM YYYY');
```

Common format conversions:

| strftime | ICU (isoFormat) |
|----------|----------------|
| `%d` | `DD` |
| `%m` | `MM` |
| `%Y` | `YYYY` |
| `%B` | `MMMM` |
| `%A` | `dddd` |
| `%H:%M` | `HH:mm` |

## Step 11: Clean Up Published Migrations

If you previously published package migrations to `database/migrations/`, remove the copies. The monorepo loads migrations directly from the package.

Check for files like `create_blog_*`, `create_shopify_*`, `create_faq_*` in your migrations directory and delete them if they are copies of package migrations.

## Step 12: Update Envoy Import Path

If you use Laravel Envoy for server tasks, update the import path in your `Envoy.blade.php` to reflect the monorepo structure:

```blade
{{-- Before (v1.x) --}}
@import('vendor/racerfish/evolve/bin/Envoy.blade.php')

{{-- After (v2.0) --}}
@import('vendor/racerfish/evolve/core/bin/Envoy.blade.php')
```

The Envoy tasks file moved into the `core/` subdirectory as part of the monorepo consolidation.

## Step 13: Clear Caches and Run Migrations

```sh
composer install
php artisan optimize:clear
php artisan migrate
```

## Step 14: Rebuild Assets

```sh
yarn backend:install
yarn backend:build
yarn theme:install
yarn theme:build
```

## Step 15: Verify

```sh
# Check the app boots
php artisan about

# Verify routes load
php artisan route:list

# Check migration status
php artisan migrate:status

# Run static analysis
php artisan analyse
```

Visit your backend panel and verify:

* Login works
* Dashboard loads
* Page editor functions (block CRUD, media picker)
* Navigation manager works
* Forms and leads display correctly
* **Pagination and search respond.** A dead panel here means the stale
  Livewire 2 assets, see
  [Delete the Livewire 2 published assets](#delete-the-livewire-2-published-assets).
* **An image renders through the `/img` route.** A failure here is the
  Intervention Image 3 driver.

Then work through
[Pitfalls Found in Real Upgrades](#pitfalls-found-in-real-upgrades). Several of
those failures cannot be seen from the console at all.

## Staying on v1.x (Legacy)

If a client site cannot be upgraded to Livewire 3 (e.g., custom frontend components that depend on LW2 patterns), you can stay on the legacy version:

```json
"repositories": [
    {
        "type": "vcs",
        "url": "git@github.com:racerfish/evolve-cms.git"
    }
]
```

```sh
composer require racerfish/evolve:v1.99
```

The `v1.99` tag points to the last pre-Livewire 3 release with the monorepo structure. This version receives critical bug fixes only.

## Updating After the Migration

Future v2.x updates (2.1, 2.2, etc.) are a single command:

```sh
composer update racerfish/evolve
php artisan migrate
php artisan optimize:clear && php artisan optimize   # the deploy runs this, catch failures locally
```

Because the composer constraint is `^2.0`, the command will bring in any new 2.x minor or patch release automatically. A major release (3.x) will require an explicit constraint bump and its own upgrade guide.

## Upgrading within 2.x

Upgrading within v2.x is `composer update racerfish/evolve`. The releases below need more than that. Newest first; a project jumping several minors works through every section it crosses, oldest first.

### To 2.22

1. **Switch the email module on where newsletters or Brevo are used.**  The `email` module (campaigns, Brevo contact sync, the Email entry in the sidebar) is off by default now. An install that never set the switch ran it on the old default and loses it with the update. Set the switch before or with the update, in the server `.env` or in the Developers tab:

   ```bash
   EVOLVE_MODULE_EMAIL=true
   ```

   The `module_switches` health check has named these installs since 2.21 (`email` under "run on their default") and warns when a Brevo key is set while the module is off. Installs without campaigns or contact sync need nothing: the module was never used there.

2. **Form mails need nothing.** The [mail templates](/panel/mail-templates) for form confirmations and notifications, the email blocks and their renderer moved into the core and work with the module off. Templates, their blocks and the form assignments stay as they are, the tables are unchanged. The panel entry moved from Email to the core section of the sidebar (`/evolve/mailtemplate`, the old address redirects), Cmd-K finds "Mail templates" as before.

3. **Published mail views.** Projects that published `email.views` keep their copy of `render/campaign.blade.php`. Block copies under `theme/views/vendor/email/render/blocks` are still read while the email module is on; move them to `theme/views/vendor/evolve/email/blocks` (publish tag `evolve.mail-views`) so they apply without the module too. A copy of the old `render/document.blade.php` is no longer used, the layout is `evolve::email.document`.

4. **Published `config/email.php`.** The theme, merge tag dialect and asset base of the rendered mail live in `evolve.mail` of `config/evolve.php` now. A published `config/email.php` that still carries `theme`, `dialect`, `merge-tags` or `asset-base` keeps winning over the core defaults, so nothing changes until the keys are removed. The environment variables (`EMAIL_BRAND_COLOR`, `EMAIL_CONTAINER_WIDTH`, `EMAIL_MERGE_DIALECT`, `EMAIL_ASSET_BASE`) keep their names.

5. **Code that references the module classes.** `Racerfish\Email\Models\MailTemplate`, `Racerfish\Email\Templates\*` and `Racerfish\Email\Services\EmailDocumentRenderer` are deprecated subclasses of their core counterparts (`Racerfish\Evolve\Models\MailTemplate`, `Racerfish\Evolve\MailTemplates\*`, `Racerfish\Evolve\Services\EmailDocumentRenderer`) and go in 3.0. Both names read and write the same rows. Route names changed from `email.template.*` to `evolve.mailtemplate.*`, translation keys from `email::templates.*` to `evolve::mail-templates.*`.

### To 2.21

1. **Require laravel/mcp and laravel/ai where they are used.**  Evolve no longer requires `laravel/mcp` and `laravel/ai`, it only suggests them. A project that uses the [MCP server](/modules/mcp-server) or the [AI chatbot](/modules/ai-chatbot) requires the package itself, in the same step as the update:

   ```bash
   composer require laravel/mcp            # MCP server (stdio or HTTP)
   composer require laravel/ai             # AI chatbot
   composer update racerfish/evolve
   ```

   Without it, `composer update` removes the package and the module stops; the site and the panel keep working. The MCP module is on by default, so check every project, including those that never connected an agent. Fleet shows which installs serve `/mcp/evolve`. After the update, the health endpoint reports a module without its package with `active: false` and `missing_packages`, and the module switches check warns. A project that does not use MCP sets `EVOLVE_MODULE_MCP=false` instead.

### To 2.19

1. **Check the timezone.** A new health check warns when `app.timezone` is `UTC`. Editors type Swiss local time into every date field, and Evolve stores and compares those values in `app.timezone` without conversion, so on UTC a scheduled block or a publication date takes effect one to two hours late. To fix it, set `APP_TIMEZONE=Europe/Zurich` in the server `.env`, make sure `config/app.php` reads `'timezone' => env('APP_TIMEZONE', 'UTC')`, and run `php artisan config:cache`. The editor input already stored then becomes correct, because it was Swiss time all along. Machine timestamps written before the switch (`created_at`, `updated_at`, the activity log) shift and show one to two hours early. A site that runs on UTC on purpose sets `HEALTH_CHECK_TIMEZONE=false`. See [Health Checks](/site/health-checks#timezone).

2. **Migrate.** `php artisan migrate` adds the `tracking.consent_cmp` and `tracking.cookiebot_id` rows to `evolve_settings`.

3. **Footprints waits for consent on opt-in sites.**  The `footprints` attribution cookie is only set after marketing consent. Opt-out sites see no change. Opt-in sites keep the attribution of a lead submitted in the same visit, but a visitor who never consents is no longer linked across visits. Declare `footprints` as a marketing cookie in the consent platform (see [Cookies Evolve sets](/site/tracking-and-consent#cookies-evolve-sets)).

4. **Sites with Cookiebot.** Choose Cookiebot as the consent platform on Settings > Tracking and enter the Cookiebot ID (or set `TRACKING_CMP=cookiebot` and `COOKIEBOT_ID`), then remove the project's own Cookiebot partial and its include, and the Cookiebot tag in the GTM container if there is one. Without this step an opt-in site with Cookiebot never writes the Evolve consent cookie, so server-side conversions, Hotjar and now also `footprints` stay denied. The tracking health check warns about it. Sites with another platform call `window.evolveConsentUpdate()`, see [Consent in the Theme](/frontend/consent).

5. **Copied theme views.** The override check now also lists copies of `theme::` views in `resources/views/vendor/theme/`:

   ```bash
   php artisan evolve:view-overrides --since=2.18.0
   ```

   A copy of `theme::head` adds `@include('theme::consentPlatform')` at its end. A copy of `theme::consentMode`, or a project consent snippet rendered instead of it, takes over the second script block of the package view (the bridge and the Cookiebot listener). A project snippet that is not a copy is not detected, compare it by hand. A copy of `settings.index` adds the platform select and the Cookiebot ID field to the consent card.

6. **Readable file links in the downloads block (optional).** File URLs now carry the name (`/media/{hash}/{slug}.{ext}`), and the copy link buttons use them. The downloads block lives in the project theme, so a project keeps linking `/media/{hash}/download`, which still works. To show the readable URL, let the link in `theme/views/components/blocks/cms/downloads.blade.php` echo `$media->filePath()` as its `href` instead of the hand built `/media/…/download` path, and give it the `download` attribute so a click still saves the file. See [File URLs](/panel/media-library#file-urls).

7. **Mail templates (email module).** `php artisan migrate` also adds `confirmation_template_id` and `notification_template_id` to `evolve_forms`, `locale` to `evolve_leads` and the table `email_templates`. Nothing changes until a template is assigned to a form: every form keeps its Markdown confirmation and notification. A copy of `form.index` adds the two template selects to the notifications card (`evolve:view-overrides --since=2.18.0` lists it), without them a form cannot pick a template. Projects that published the email render views (`email.views`) keep their copy of `render/campaign.blade.php`; the new layout `render/document.blade.php` and the block `render/blocks/leadSummary.blade.php` come from the package. See [Mail Templates](/panel/mail-templates).

8. **Google service account.**  The package no longer ships a service account for the GA4 Data API (it was a Racerfish key, committed by mistake; the key is being rotated). A site that shows the analytics dashboard without its own `GOOGLE_APPLICATION_CREDENTIALS` loses the page views after the update and sees a notice on the dashboard instead. Before updating, create a service account in the site's Google Cloud project with the Analytics Data API enabled, add its email as a viewer of the GA4 property, put the JSON file outside the web root and set `GOOGLE_APPLICATION_CREDENTIALS` to its path (absolute, or relative to the project root). Sites that already set the variable for Cloud Vision or auto-translate need nothing; the same file now serves the dashboard. A copy of `dashboard.index` adds the notice for the missing file (`evolve:view-overrides --since=2.18.0` lists it). See [Environment Variables](/getting-started/environment-variables#other-keys).

### To 2.18

1. **Update and migrate.**

   ```bash
   composer update racerfish/evolve
   php artisan migrate
   ```

   Two migrations add rows to `evolve_settings`: the `mail` group and `tracking.consent_via_cmp`. Projects that want the laravel/mcp security fix also run `composer update laravel/mcp -W` (1.x is allowed now).

2. **Import the settings once per database.** The panel now saves site name, branding, tracking, consent, verifications and mail addresses to the [settings store](/panel/settings-store). Until a site imports, nothing breaks: the `.env` keeps applying and the panel shows an import hint on General, Tracking and SEO. Take the values over on every environment with its own database:

   ```bash
   php artisan evolve:settings:import --dry-run
   php artisan evolve:settings:import
   ```

   The import fills only empty properties, skips package defaults and never edits the `.env`. The `.env` lines stay as fallback for fields emptied later. From now on, change these values in the panel; editing them in the `.env` has no effect once a value is stored.

3. **Set the notification address.**  Forms without an administrator address used to mail the sender address. Now their leads go to the notification address, and without one the internal notification is skipped (a warning lands in the log; the visitor's confirmation still goes out). Check the form list in the panel for "No recipient" badges, then fill the notification address on Settings > Mail, or set `EVOLVE_MAIL_NOTIFICATION_ADDRESS` before running the import. Health failure mails now go to the alert address on the same tab; `HEALTH_WEBMASTER_ADDRESS` keeps working as its fallback.

4. **Consent on opt-in sites.** A site running `opt-in` with the cookie banner off keeps rendering as before (denied by default), but the panel refuses to save the settings until the banner is on, the policy is `opt-out`, or the new **Consent via CMP** option is set. Sites with Cookiebot or another platform in GTM switch the CMP option on.

5. **Company identity.** Enter legal name, address, contact, profiles and logo on Settings > Company to get the site-wide schema.org `Organization`. Without a legal name nothing is emitted. Articles and blog posts lose the `author` and `publisher` built from a `company.name` translation; projects that defined one move the data into the Company tab.

6. **Copied panel views.** Run the check and bring every listed copy up to date:

   ```bash
   php artisan evolve:view-overrides --since=2.17.0
   ```

   Entries for 2.18.0 cover `settings.index` (store inputs, Company and SEO tabs, the consent card, the validation errors at the top), `form.index` (notification address placeholders, tab title), `activity.index` (type labels and aliases), `resource.create`, `resource.edit`, `user._form` and `invite._form` (browser tab title moved into the resource views), `block.propType.address` (Algolia Places removed) and `components.layout.sidebar` (translated health label). A copy of `settings.index` that still posts the old variables has them dropped and logged, so its fields silently stop saving.

7. **MCP tokens.** Over HTTP, write tools now check the panel permissions of the token's user. A token whose user lacks `pages.create`, `pages.edit`, `pages.publish` or `pages.delete` gets a tool error instead of a change. Grant the role the permissions or issue the token for a user who has them. The local stdio transport is unaffected.

8. **New installations** set these values in the panel and leave the store-backed variables (`APP_NAME`, `GTM_ID`, `MAIL_FROM_*` and the rest of the bridge) out of the `.env`.

### To 2.14

* Projects that published `blocks/cms/aiChatbot.blade.php` must send the block ID as `block` instead of `system_prompt`, otherwise the default instructions apply. Prompts composed on the client belong in an agent subclass via `ai-chatbot.agent`.
* Projects with their own scroll up button in the bottom right corner should lift it while the widget is on.

### To 2.13

1. **Update the package** locally and commit the lock file, then republish the panel assets.

   ```bash
   composer update racerfish/evolve
   php artisan vendor:publish --tag=evolve.assets --force
   ```

2. **Check copied panel views.** Projects that copy Evolve views into `resources/views/vendor/evolve` or `theme/views/vendor/evolve` keep rendering the old markup after the update. Run the check and bring every listed copy up to date, or delete copies that are no longer needed:

   ```bash
   php artisan evolve:view-overrides --since=2.12.1 --strict
   ```

   It also flags Font Awesome tags and white text on `bg-primary` in any copy, and copies of `page.list`, which the package no longer ships. Module views (`blog::`, `event::` and so on) are not covered, compare those by hand. See [Overridden Panel Views](/getting-started/upgrading#overridden-panel-views).

3. **Custom panel code.** `window.makeEditor`, `makeChart`, `makeTree`, `makeSelect` and the date pickers return a Promise now, and `window.makeCkEditor` is gone. Icons in your own panel views use `<x-evolve::icon name="…" />`. Font Awesome strings in blueprints keep working through an alias table.

4. **Content Security Policy.** The panel loads no web fonts any more, so `fonts.googleapis.com` and `fonts.gstatic.com` are only needed if the website itself uses them.

### To 2.12

1. **Update the package** locally and commit the lock file.

   ```bash
   composer update racerfish/evolve
   php artisan migrate
   ```

   Four migrations run: `evolve_settings` (the [settings store](/panel/settings-store)), the permission catalogue (see [Roles](/getting-started/roles#permissions)), and two for Shopify. Projects with a customised `evolve.roles` keep that config in place before migrating, because the permission grants are derived from it.

2. **Back up the live database before the first deploy.**  The deploy runs `migrate --force` on the server. On installs with the Shopify module, the foreign key migration first deletes rows in `shopify_products_data`, `shopify_products_i18n`, `shopify_product_has_collections` and `shopify_collections_i18n` whose product or collection no longer exists, then adds cascading foreign keys. Those rows were unreachable, but they are gone afterwards. Take a dump on the server (see [Shopify](/modules/shopify#upgrading-to-2-12)) and read the counts in `storage/logs` after the deploy (`SHOPIFY: Deleted <n> orphaned rows from ...`).

3. **Copy changed theme files** if the project publishes the starter theme: `theme/views/components/ui/tile.blade.php`, `theme/views/components/blocks/cms/{tiles,logoCloud,hero,simpleTextMultiCol,tabPanels}.blade.php` and `theme/src/lib/oembed-parser.ts`, then rebuild the assets (#228). If `vendor:publish --tag=shopify.assets` ever ran, republish `public/vendor/shopify/cart-store.js`. A project with its own copy of `components/image.blade.php` mirrors any changed widths or sizes in `image.conversions` ([Image Rendering](/frontend/image-rendering#queued-conversions)).

4. **Sites embedded in an iframe on another domain:** set `EVOLVE_FRAME_OPTIONS=` (empty) in the server `.env`, and check that `public/.htaccess` does not set `X-Frame-Options` itself (the starter's does). Otherwise the embed stops rendering after the deploy. See [Security Headers](/site/security-headers#embedding-the-site-in-an-iframe).

5. **Decide on the queue for image conversions.** With `QUEUE_CONNECTION=sync` nothing changes: variants keep rendering on their first request. To move the work into a worker, set `QUEUE_CONNECTION=database` (the `jobs` table must exist), add the worker cron next to `schedule:run` and add the warm command to the after hooks:

   ```bash
   * * * * * cd ~/webhome/public_html/www/current && php84 artisan queue:work --stop-when-empty --max-time=55 >> /dev/null 2>&1
   ```

   ```php [config/evolve.php]
   'hooks' => [
       'after' => [
           'php84 artisan evolve:images:warm --missing',
       ],
   ],
   ```

   Adjust the path to the environment's target and the binary to its `php-bin`. The queue health check turns itself on as soon as the driver is not `sync`, so a stopped worker shows up on `/evolve/health`. Details in [Image Rendering](/frontend/image-rendering#queued-conversions) and [Deployment](/getting-started/deployment#cron-jobs).

6. **Deploy.** The first deploy with 2.12 moves `storage/app` of the release into `shared/storage/app` next to `releases/` and links every release to it. No configuration, no manual step. Verify on the server:

   ```bash
   php artisan evolve:ssh live
   ls -l current/storage/app              # app -> ../../../shared/storage/app
   ls -d releases/*/storage/app.migrated  # nothing: the move completed
   ```

   If the deploy reports that releases could not be linked, the new release is live but no old release was purged; run the deploy again. Rolling back works as before and links the older release first. Backup scripts that read `releases/<release>/storage/app` must read `shared/storage/app` or go through `current/`. See [Deployment](/getting-started/deployment#shared-storage).

7. **Page cache (sites with `APP_PAGE_CACHE=true`).** Entries written by earlier releases may hold a page rendered for a logged-in user, with the info bar and that session's CSRF token (#236). `php artisan deploy` clears the page cache of the new release before it goes live, so a normal deploy removes them. If the site was updated any other way, or to be certain, clear and rebuild once on the server:

   ```bash
   php artisan evolve:clear-page-cache
   php artisan evolve:rebuild-page-cache
   ```

   See [Page Cache](/site/page-cache#what-is-never-written).

8. **Move settings into the store** (optional, per site). Review first, then import:

   ```bash
   php artisan evolve:settings:import --dry-run
   php artisan evolve:settings:import
   ```

   Values equal to the package default are skipped and keep following the package. The `.env` is not changed. The panel still writes the `.env` in 2.12, and a stored value wins over it: after importing, edit those values in the store, not in the panel. See [Settings Store](/panel/settings-store).

9. **Check the security headers** with `curl -sI https://<domain>/`: `X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy` on every environment, `Strict-Transport-Security` only on live over https. A Content-Security-Policy is opt-in; start with `EVOLVE_CSP_REPORT_ONLY=true`. Pages served from the static page cache bypass PHP and need the headers from `.htaccess`. See [Security Headers](/site/security-headers).

10. **Sentry.** With `sentry/sentry-laravel` installed, events carry the tags `site` and `environment_type` and the release `evolve@<version>+<release directory>`. Nothing to configure. `php artisan evolve:env:verify live` now has a `Sentry DSN` line that warns when the live `.env` has no DSN. See [Error Tracking](/site/error-tracking).

11. **Shopify installs:** the Admin API token of every store needs the `read_products` scope for the status sync. The daily purge of deleted products is opt-in (`SHOPIFY_PURGE_DELETED_AFTER_DAYS`). See [Shopify](/modules/shopify).

## Overridden Panel Views

A project can copy any Evolve view into `resources/views/vendor/evolve/` (or `theme/views/vendor/evolve/`). Laravel then renders the copy instead of the package view. The copy never updates itself. When a release changes the package view, the project keeps rendering its old copy, and that usually fails quietly: a dead editor, a missing button, a script error in the browser console.

Since 2.13 the package keeps a log of view changes that a copy has to follow by hand, and a command reads it. Where copies go, which files can be published and which cannot is explained in [Publishing and Overriding Package Files](/develop/publishing-and-overriding).

### Check before every upgrade

Run this with the version the project is on now:

```sh
php artisan evolve:view-overrides --since=2.12.1
```

The table lists every copied view and how many known changes it has to follow. Below the table each affected view gets its changes spelled out. A copy with `none known` is still a copy: it misses every future change as well, so it is worth asking whether it is still needed.

Add `--strict` to make the command exit with 1 while a copy has changes to follow, for example in a deploy script or CI job:

```sh
php artisan evolve:view-overrides --since=2.12.1 --strict
```

### Bring a copy up to date

1. Compare the copy with the new package view:

   ```sh
   diff resources/views/vendor/evolve/components/form/input.blade.php \
        vendor/racerfish/evolve/core/views/components/form/input.blade.php
   ```

2. If the project change is small, prefer deleting the copy and solving the need another way (a blueprint option, a config value, CSS in the theme). Every copy is a future upgrade task.

3. If the copy has to stay, start again from the new package view and reapply the project change on top. Patching the old copy line by line tends to miss changes the log does not describe.

4. Run the command again. The view drops out once you pass the new version as `--since`.

### Changes logged so far

| Version | View | What a copy has to change |
|---|---|---|
| 2.13.0 | `components.form.input` | `window.makeEditor()` returns a Promise. Replace `editor = window.makeEditor(...)` with `window.makeEditor(...).then((instance) => editor = instance)`. |
| 2.13.0 | `app`, `guest`, `blank` | Remove the three Google Fonts `<link>` tags. The panel uses the system font stack. |
| 2.13.0 | any panel view with `<i class="fal fa-…">` | Font Awesome is gone from the panel. Replace each tag with `<x-evolve::icon name="…" />` (see [Icons](#_2-13-icons)). The command finds these by content, whatever the view is called. |
| 2.13.0 | `app`, `guest`, `blank` | Replace the `theme-color` block on `<body>` with style="{{ \Racerfish\Evolve\Support\PanelTheme::cssVariables() }}" and read the favicon colour with `PanelTheme::accent()`. |
| 2.13.0 | `components.layout.sidebar` | Grouped into Core and Modules with `.sidebar__label` headings. Search and website sit as icon buttons next to the logo, media moved into Core, the system screens live in the user menu at the bottom. Start again from the package view. |
| 2.13.0 | `components.layout.branding` | Renders {{ $slot }} after the name, the sidebar puts search and website there. |
| 2.13.0 | `components.layout.header` | The mobile header shows only the menu button, logo and search. Everything else is in the sidebar, the `header` stack is gone. |
| 2.13.0 | `components.toggle` | The checked state is `bg-primary` (monochrome) instead of the green gradient. |
| 2.13.0 | any panel view with `bg-primary` and `text-white` | `primary` is the ink of the mode now. Use `text-on-primary`. Found by content. |
| 2.13.0 | `resource.edit`, `resource.create` | Title, breadcrumb and save buttons moved into `<x-evolve::action-bar form="createForm" …>` above the form. The back link and the button row below the form are gone, the form carries `id="createForm"`. Start again from the package view. |
| 2.13.0 | `page.show` | The header (back link, page switcher, badges, live editing) moved into `<x-evolve::action-bar form="pageForm" …>`. The settings form carries `id="pageForm"`, its three save buttons are gone. |
| 2.13.0 | `settings.index`, `user.profile` | Heading and save button moved into the action bar (`form="settingsForm"`, `form="profile"`). The password form keeps its own button, labelled "Change password". |
| 2.13.0 | `form.show`, `section.show` | Back link and heading moved into the action bar. |
| 2.13.0 | `user._form`, `invite._form` | The hidden `<x-evolve::form.redirect>` field is gone. "Save" stays on the form, "Save & close" returns to the list. |
| 2.13.0 | `activity.index`, `form.index`, `language.index`, `lead._form`, `navigation.index`, `page.index`, `redirect.index`, `region.index`, `section.index`, `staticstring.index`, `tag.index` | Edit and create dialogs are side drawers. Replace `<x-evolve::layout.modal>` with `<x-evolve::layout.drawer form="…">`, give the form that id and drop its save button. A heading inside the dialog becomes the `title` prop. |
| 2.13.0 | `user.index`, `invite.index` | Users are edited in a drawer on the list, the create button became "Invite". Invitations are created in a dialog on the invite list. Start again from the package view. |
| 2.13.0 | `components.form.input`, `block.editor` | The editor toolbar and the bulk action head stick with the `sticky-top` class instead of `sticky -top-8`, so they stay below the action bar. |
| 2.13.0 | `components.search` | Rebuilt as the body of the Cmd-K palette (views, actions, content records, keyboard navigation). Start again from the package view. |
| 2.13.0 | `components.layout.header` | The search overlay moved into `<x-evolve::layout.command-palette />`, rendered after `</header>`. Replace the `isSearchOpen` block with that tag and drop the `@role` wrapper around the search button. |
| 2.13.0 | `components.layout.sidebar` | The search button next to the logo shows for every role. Drop its `@role` wrapper. |
| 2.13.0 | `app` | Optional. The Cmd/Ctrl+K listener calls `event.preventDefault()` and toggles the palette, `openSearch()` selects the input. Copies without it keep working. |
| 2.13.0 | `components.dropdown` | Optional. The menu logic is the registered Alpine component `dropdown`: use `x-data="dropdown(@js($alignment))"` and `x-on:click="toggle()"` on the trigger. Copies with the inline script keep working but repeat about 3.5 KB in every menu. |
| 2.13.0 | `page.index` | Optional. Visibility is a small icon in the row, changing it and the live link moved into the row menu. Copies with the old privacy dropdown keep working. |
| 2.13.0 | `page.edit` | The live editor header moved into `<x-evolve::action-bar>` inside a `page-live-editor` column, and the create page button is gone (Cmd+K has it). The Responsive, Desktop and Mobile switch is an `<x-evolve::segmented>` control bound with `x-bind:aria-pressed`. |
| 2.13.0 | `block.editor` | "Create block" is a secondary button everywhere (`type="secondary"` on `<x-evolve::block.create-picker>`). The solid button belongs to Save in the action bar. |
| 2.13.0 | `media.index` | The type filter is an `<x-evolve::segmented>` control with an "All" item, so clicking the active type no longer clears it. "Not attached" is a `btn-toggle` with `aria-pressed`, grid and list a second segmented control. |
| 2.13.0 | `media._form` | The full screen media viewer is a large drawer: `<x-evolve::layout.drawer form="mediaForm" size="lg">`, preview on top, fields below, delete and replace in the footer. Start again from the package view. |
| 2.13.0 | `media._row` | Optional. Slim row with a 32 px thumbnail. Copies with the old markup keep working, they are only taller. |
| 2.13.0 | `health.index` | Labels are translated (`evolve::panel.health.*`), results are cards sorted with failures first, the header buttons are secondary. |
| 2.13.0 | `activity.index` | The subject filter is an `<x-evolve::segmented>` control with an "All" item. |
| 2.13.0 | `page.list` | Removed. The view was not rendered anywhere, delete the copy. |
| 2.19.0 | `theme::head` | Ends with `@include('theme::consentPlatform')`, the consent platform script. |
| 2.19.0 | `theme::consentMode` | Second script block: `window.evolveConsentUpdate()` and the Cookiebot listener. Both script tags carry `data-cookieconsent="ignore"`. |
| 2.19.0 | `settings.index` | The consent card gains `tracking.consent_cmp` and `tracking.cookiebot_id`. |

Since 2.19 the command also checks copies of the theme views (`resources/views/vendor/theme/`), listed with a `theme::` prefix. The log itself lives in `core/src/Support/ViewOverrides.php`: `CHANGES` for changes to a specific view, `PATTERNS` for markup that breaks in any panel view copy. Frontend and mail views are not checked against `PATTERNS`, because they render with the project's own assets.

## Panel Refresh (2.13 and Later)

The control panel is being redesigned over several releases (issue [#210](https://github.com/racerfish/evolve-cms/issues/210)): monochrome in light and dark mode, less decoration, and a much smaller payload. This section grows with each release. Run the [override check](#overridden-panel-views) on every step.

### 2.13: Payload and fonts

* **Smaller bundle.** The panel loads about 470 KB instead of 2.3 MB of JavaScript, CSS and fonts per page (158 KB instead of 638 KB with gzip). Charts, the rich text editor, the navigation tree, select boxes and date pickers load when a screen needs them.

* **Panel JavaScript API.** `window.makeChart`, `makeEditor`, `editorAi`, `makeTree`, `makeSelect`, `makeDatePicker` and `makeDateTimePicker` keep their names and arguments but now return a Promise. Code that ignores the return value keeps working. Code that uses it has to wait for it:

  ```js
  // before
  editor = window.makeEditor(el, content, bubbleMenu, $dispatch, options)

  // after
  window.makeEditor(el, content, bubbleMenu, $dispatch, options).then((instance) => editor = instance)
  ```

* **CKEditor removed.** `window.makeCkEditor` no longer exists. Nothing in Evolve used it; the rich text editor is TipTap.

* **Fonts.** The panel uses the system font stack (San Francisco on macOS, Segoe UI on Windows, Roboto on Android) and loads no web font, so there is no flash of unstyled text and no request to Google Fonts. A Content Security Policy no longer needs `fonts.googleapis.com` or `fonts.gstatic.com` for the panel. Monospace text uses the system monospace font. This is a deliberate exception to the Racerfish Studios rule of Inter everywhere: the panel is a tool, and a font that loads on every page view costs more than it gives.

* **After the update**, republish the panel assets as always: `php artisan vendor:publish --tag=evolve.assets --force`.

### 2.13: Icons

The panel no longer uses Font Awesome. Icons come from [Nucleo](https://nucleoapp.com) and render as `<svg><use>` references into one sprite (`/evolve/icons.svg`), which the browser caches. No icon JavaScript runs any more, and icons no longer flicker after a Livewire update.

* **In Blade**, use the component:

  ```blade
  {{-- before --}}
  <i class="fal fa-fw fa-search"></i>

  {{-- after --}}
  <x-evolve::icon name="magnifier" />
  <x-evolve::icon :name="$blueprint->icon" class="mr-2" />
  ```

  The icon is `1.125em` square and follows the text colour, like a Font Awesome `fa-fw` icon did.
* **Blueprints, module menus and the activity log** keep their Font Awesome strings working. `icon: fad fa-film`, `->icon('fad fa-th-large')` and stored names like `trash-alt` resolve through an alias table (`core/assets/icons/aliases.php`) to the closest Nucleo icon. Unknown names show a neutral cube. New blueprints should use the Nucleo name directly (`icon: film`). The names the panel ships are listed in `core/assets/icons/manifest.php`.
* **Frontend views are not affected.** A theme that uses Font Awesome on the website keeps loading it through its own assets. The Nucleo icons are licensed for the control panel only and must not be used in project frontends.
* **The private Font Awesome registry is gone.** `core/assets/.npmrc` was removed, so building the panel assets needs no token any more.

### 2.13: Monochrome, light and dark

The panel keeps its light and dark mode (switch in the user profile, default follows the system), but the interface itself has no colour any more.

* **Monochrome.** The primary button, focus rings, the active navigation entry, checked toggles and checkboxes use the ink of the current mode: near black in light mode, near white in dark mode. In Tailwind, `primary` is that ink now and `on-primary` the colour that reads on it. Status colours stay for status: green, red, amber and blue on badges, alerts and dots.
* **Brand colour.** `evolve.theme-color` (or `APP_BACKEND_THEME_COLOR`, or the panel colour in the branding settings) still exists, default `#E78401`. It colours only the logo, the loading bar at the top, the upload bar and loading placeholders, available in Tailwind as `accent`. The old default `#1D4ED8`, which published configs still carry as their fallback, counts as "not chosen" and gets the new default.
* **Neutrals.** Dark mode: page `#262626`, cards `#2D2D2E`, fields `#363738`, lines `#424345`. Light mode: page `#F4F4F4`, cards white, lines `#E6E7E8`. The Tailwind `gray` scale of the panel follows these values, so `light dark:` class pairs keep working.
* **Navigation.** The sidebar holds content only: Core (dashboard, pages, navigation, sections, forms, leads, media) and Modules. Search (⌘K) and the website link are icon buttons next to the logo. Users, languages, regions, static strings, redirects, tags, caches, activity, health and settings moved into the user menu at the bottom of the sidebar, which opens upwards. Roles decide what each person sees, as before. The region switcher stays above the user menu. On phones the header shows only the menu button, logo and search.
* **Shapes.** Two radii remain: 6 px for controls and 10 px for cards, lists and dialogs. The panel's own shadow set is gone.
* **Copied views** that put white text on `bg-primary` show up in the [override check](#overridden-panel-views): in dark mode that text is white on near white. Use `text-on-primary`.

### 2.13: One action bar per screen

Every edit screen has one sticky bar at the top of the content: breadcrumb and title on the left, context actions and saving on the right. It stays in view on long forms, so saving no longer needs a scroll to the end.

* **One place to save.** The save buttons in card footers and below forms are gone. The bar submits the form from outside through the `form` attribute. While that form has unsaved input the bar says so, and leaving the page asks first.
* **Only redirects on this site.** Save & close submits `_redirect`. Resource controllers now follow it only to a path or a URL on the panel's own host and otherwise stay on the form (or go to the list after creating). Before 2.13 any URL was followed.
* **Save stays, Save & close leaves.** "Save" reloads the form, "Save & close" returns to the list. The module forms (blog, event, FAQ, project, company, email) no longer carry a hidden `_redirect` field, which used to send every save back to the previous URL. Remove `<x-evolve::form.redirect>` from copied module forms as well.
* **Breadcrumb instead of back links.** The "Back" links above titles are gone, the breadcrumb in the bar leads back to the list.
* **In your own panel views** use the component:

  ```blade
  <x-evolve::action-bar
      :title="$item->title"
      :crumbs="[route('app.thing.index') => __('Things')]"
      form="thingForm"
      :close-url="route('app.thing.index')">
      <x-slot:actions>
          <a href="{{ $item->url }}" class="btn btn-link">Show live</a>
      </x-slot:actions>
  </x-evolve::action-bar>

  <x-evolve::form id="thingForm" method="PUT" :action="route('app.thing.update', $item)">
      …
  </x-evolve::form>
  ```

  Without `form` the bar shows only breadcrumb, title and actions. `close-url` adds "Save & close", which submits `_redirect` like the old button did. `width` takes the max width class of the content column (`width="max-w-5xl"`) so the bar lines up with a narrow screen. A `badges` slot sits right after the title.
* **Resource controllers** feed the breadcrumb of the generic edit and create screens with `$_resourceLabel`. It is guessed from the `{package}::views.{plural}` translation (for example `blog::views.posts`) and falls back to the plural model name. Set `protected ?string $resourceLabel = 'app.things';` (a translation key or plain text) on a controller where the guess is wrong.
* **Sticky elements** inside a screen use two classes that know about the bar: `sticky-top` sticks right below it (editor toolbars, list heads), `sticky-column` sticks a side column from `lg` on with a gap below it. A copied module form that still uses `sticky top-0` for its side column slides under the bar. Module views (`blog::`, `event::` and so on) are not covered by the override check, compare them by hand.
* **Lists that edit in a dialog** (media, redirects, tags, languages, static strings and similar) use a side drawer with its own Cancel and Save footer, see [Records open in a side drawer](#_2-13-records-open-in-a-side-drawer).

### 2.13: Records open in a side drawer

Lists that edit a record without leaving the page (redirects, tags, languages, static strings, regions, sections, navigations, forms, leads, the activity log, the new page dialog and the module categories, teams and collections) open it in a drawer on the right instead of a centred dialog. The list stays visible behind it, `?edit={id}` and `?create=true` links keep working.

* **One place to save.** The drawer has a fixed footer with Cancel and Save. Save submits the form inside through its `form` attribute, so the save button inside the form is gone.
* **In your own Livewire lists** use the drawer like the modal, it takes the same props and closes the same way (`close-modal`):

  ```blade
  <x-evolve::layout.drawer
      form="thingForm"
      :title="$this->isEditing ? __('evolve::panel.crud.edit') : __('evolve::panel.crud.create')">
      <form id="thingForm" wire:submit="save">
          …
      </form>

      <x-slot:footer>
          {{-- optional extra actions, left of Cancel and Save --}}
      </x-slot:footer>
  </x-evolve::layout.drawer>
  ```

  `size` is `xs`, `sm` (default), `md` or `lg`. Without `form` the drawer has no Save button, useful for read only details. `save-label` renames Save. `php artisan evolve:resource` generates the drawer for new resources.
* **Users and invitations.** A user opens in a drawer on the user list (name, email, role, picture). Users are no longer created there: the old create page failed on the required password, users come from invitations. "Invite" opens a dialog with email and role, invitations are only created and deleted. Only developers can hand out the developer role, in the drawer, the dialog and the old `evolve/invite` form alike. The `evolve/user/{id}/edit` page still works for existing links.
* **Dialogs that are a task, not a record** stay centred: the block editor, the content generator, the form builder fields and the language import preview.
* **Copied module views** (`blog::category._form` and the like) are not covered by the override check. A copy that still uses `layout.modal` keeps working as a centred dialog.

### 2.13: Cmd-K jumps to screens and actions

Cmd+K (Ctrl+K on Windows and Linux) or the magnifier next to the logo opens a palette instead of the full screen search. It finds three things:

* **Views.** Every panel screen the user may open: pages, redirects, settings, each global collection, module lists and the developer tools.
* **Actions.** "New page", "New blog post", "New redirect", "Invite user" and similar. They open the create page, or the list with `?create=true`, which opens its drawer.
* **Content.** Pages, posts and the other searchable models, as before, below views and actions.

Arrow keys move through all three, Enter opens, Cmd/Ctrl+Enter opens in a new tab, Esc closes. Views and actions are matched in the browser, so they appear while typing. They match the label in the panel language, the English label and extra keywords, so "redirects" finds "Umleitungen".

* **Access follows the routes.** A command is only listed when its route exists and the user passes every `role:` middleware on it. Module commands also respect `evolve.roles.modules`. Nobody sees a screen that would answer with 403.
* **Everyone gets the palette.** The search button used to be limited to developers, admins and content managers. Now every panel user gets it, a CRM manager finds leads and the profile. Searching content stays limited to the roles in `evolve.roles.search` (default `developer`, `admin`, `content_manager`). Add that key to a published `config/evolve.php` only if you want other roles.
* **Your own screens.** Register them in a service provider or pass them with the module, see [Global Search and Command Palette](/custom-modules/global-search#command-palette).
* **Above drawers.** The palette is a modal dialog, so Cmd+K also works while a drawer or dialog is open. Esc closes the palette and leaves the drawer open.
* **Result headings.** Content results are grouped under the model's panel name ("Seiten", "Beiträge"). Your own searchable models can name their group with a static `getSearchableGroupLabel()`, otherwise the plural class name is used.

### 2.13: Slim list rows

Lists are denser and quieter: rows are 48 px high instead of about 64, separated by a fine line instead of alternating stripes, and the icon buttons in a row (the ⋮ menu, quick links) have no frame until hovered. The title carries the row.

* **Page list.** Visibility is a small monochrome icon before the title (eye, crossed eye, lock), with the state as tooltip. Changing it moved into the row menu under "Visibility", next to the bulk action that already did the same. The separate "show live" button is gone, the path is the live link and the menu has "Show page live". The homepage and the region show as a house icon and the region code instead of coloured badges.
* **Smaller HTML.** Every ⋮ menu used to carry its own 3.5 KB copy of the menu script, and created its positioning helper on page load. The script is now registered once, the helper is created on first open. The page list of the demo site went from 305 KB to 120 KB of Livewire HTML, every list with row menus shrinks the same way, and each search or sort sends less over the wire.
* **Menus.** A menu grows to 32 rem or 80 % of the window, whichever is smaller, before it scrolls. A `max-h-*` class passed as `menu-class` now takes effect. The bulk selection menu opens to the right of its button.
* **Your own lists** get the new rows through the `.list__item` class and `<x-evolve::resource.list-item>`, nothing to change. A button in a row that should keep its frame uses something other than `btn-secondary btn-square`, for example `btn-sm btn-secondary`.

### 2.13: One solid button per view, media in a drawer

Every view now has at most one solid button, and it is the action that saves or creates. States such as an active filter or the chosen view mode are no longer solid buttons, so they cannot be mistaken for an action.

* **Segmented control.** A choice of exactly one option (view mode, type filter) is `<x-evolve::segmented>` with `<x-evolve::segmented.item>` inside: a recessed track, the chosen item raised. Where "none" is a valid choice it is an explicit "All" item instead of clicking the active option a second time. Pass `active` for state held by Livewire, or bind `x-bind:aria-pressed` for state held by Alpine. `icon-only` shows the icon with the label as tooltip, `label-class="hidden xl:inline"` falls back to the icon on narrow screens.
* **On/off filters** next to it use `btn btn-secondary btn-toggle` with `aria-pressed`. When on, the button is tinted, never solid.
* **Page editor.** Save in the action bar is the only solid button, "Create block" is outlined. The live editor has the same action bar at the top of its block column, and the preview size switch is a segmented control.
* **Media.** Opening a file shows it in a large drawer on the right instead of a full screen overlay: preview on top (a click on an image still sets the focal point), fields below, Delete and Replace file next to Cancel and Save. The list keeps its place behind it. The toolbar has "All, Images, Documents" as a segmented control, "Not attached" as a toggle and grid or list as a second segmented control. List rows follow the slim row style.
* **Health.** Translated, failing checks first, and the run and purge buttons are no longer solid.
* **Activity log.** The subject filter is a segmented control.

## Pitfalls Found in Real Upgrades

These are the failures that actually cost time on client sites. Every one of
them passes `composer update` and only shows up later, some of them only in the
browser or only on the server. Walk this list before declaring an upgrade done.

### Delete the Livewire 2 published assets

**Symptom:** the backend renders, but nothing reacts. Pagination does nothing,
search does nothing, no `wire:click` anywhere in the panel fires. No PHP error,
because the failure is a 404 on a script.

**Cause:** `public/vendor/livewire/` is left over from the old
`vendor:publish --tag=livewire:assets` step. Its manifest is the Livewire 2
format, where the value is a full query string rather than a bare hash:

```json
{"/livewire.js":"/livewire.js?id=90730a3b0e7144480175"}
```

Livewire 3 appends its own `?id=` to that value and emits a URL that cannot
resolve:

```
/vendor/livewire/livewire.js?id=/livewire.js?id=90730a3b0e7144480175
```

Worse, the mere presence of that manifest makes Livewire 3 serve the published
file instead of its own route, so even a corrected URL would load the Livewire 2
bundle.

**Since v2.11** Evolve detects a stale bundle and republishes it on the next
console boot (`composer install`, or the `artisan optimize` a deploy runs),
`evolve:deploy` refuses to sync one, and `evolve:env:verify` reports it. On
older versions, or to be sure:

**Fix:** delete the directory. Livewire 3 then serves `/livewire/livewire.js`
from its own route.

```sh
rm -rf public/vendor/livewire
```

::: warning
`public/vendor` is usually gitignored, so this deletion is not carried by a
commit. Each machine needs it, and the servers only lose the stale copy through
the `rsync --delete` of the next deploy. Verify after deploying:

```sh
php artisan evolve:ssh <environment>
ls public/vendor    # expect `evolve` only
```

:::

Removing the publish line from `composer.json` (Step 4) stops the directory
being recreated. It does not remove what is already on disk.

### Never run the upgrade with `composer update --no-scripts`

**Symptom:** the backend panel renders and nothing responds. The console shows,
once per component:

```
Uncaught TypeError: Cannot read properties of undefined (reading 'entangle')
    at Object.initializeComponent (app-XXXXXXXX.js)
    at Object.discoverUninitializedComponents (app-XXXXXXXX.js)
```

**Cause:** `public/vendor/evolve` is published by the `evolve.assets` hook in
`post-autoload-dump`. `--no-scripts` skips it, so the previous major's backend
bundle stays on disk. That bundle carries Alpine 2, while the v2 Blade views
compile Livewire 3's `@entangle` into `window.Livewire.find(...).entangle(...)`.
Alpine 2 evaluates `x-data` on `DOMContentLoaded`, before `window.Livewire`
exists, so `find()` returns undefined and the property access throws. Component
initialization aborts and every `wire:click` in the panel is dead, pagination
included.

This is the same Alpine 2 versus Livewire 3 skew described for 2.0.x, reached by
a different route: there the package shipped Alpine 2, here a current package is
running last year's published bundle.

**Fix:**

```sh
php artisan vendor:publish --force --tag=evolve.assets
```

Then delete the bundles the new manifest no longer references, so a later
debugging session is not misled by files that are still being deployed.

**Guard it.** The published manifest must match the installed package's:

```php
$this->assertSame(
    json_decode(file_get_contents(base_path('vendor/racerfish/evolve/core/assets/public/build/manifest.json')), true),
    json_decode(file_get_contents(public_path('vendor/evolve/manifest.json')), true),
);
```

::: tip
`--no-scripts` is a reasonable habit while resolving dependencies, since the
hooks boot the app and the app cannot boot mid-upgrade. Just run
`composer dump-autoload` (or the publish above) once the upgrade is complete.
:::

### A deploy can leave PHP-FPM serving the previous release

**Symptom:** after a successful deploy the site still references assets from the
previous release. Requesting those files gives 404, because the web server
serves static files from the new release while PHP still renders the old paths.
`php artisan tinker` on the server renders the new paths correctly, which is what
makes this confusing: the application is fine, only the FPM workers are stale.

**Cause:** opcache in the FPM pool, holding state resolved through the `current`
symlink before it was repointed.

**Fix:** reset opcache from inside the pool. The CLI has its own opcache, so
`php -r 'opcache_reset();'` over SSH does nothing for the web requests. Reset it
through an HTTP request, then remove the file:

```sh
NAME="_opcache-reset-$(openssl rand -hex 8).php"
echo '<?php opcache_reset();' > "public/$NAME"   # in the new release
curl -k "https://<host>/$NAME"
rm "public/$NAME"
```

The random name keeps anyone else from calling the file in the seconds it
exists.

Verify with the asset hash in the served HTML rather than by eye, and always
after a deploy that changed published assets.

### `config/livewire.php` overrides whole arrays, not single keys

Livewire merges the app config over its own with a shallow `array_merge`. A
partial `temporary_file_upload` block therefore deletes every key it does not
repeat, leaving uploads without `disk`, `directory`, `middleware`,
`preview_mimes`, `max_upload_time` and `cleanup`.

Repeat the defaults and deviate only where you mean to:

```php
'temporary_file_upload' => [
    'disk' => null,
    'rules' => 'file|max:50000', // the media manager needs more than the 12 MB default
    'directory' => null,
    'middleware' => null,
    'preview_mimes' => [
        'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
        'mov', 'avi', 'wmv', 'mp3', 'm4a',
        'jpg', 'jpeg', 'mpga', 'webp', 'wma',
    ],
    'max_upload_time' => 5,
    'cleanup' => true,
],
```

Check what survived:

```sh
php artisan tinker --execute='dump(array_keys(config("livewire.temporary_file_upload")));'
```

### Intervention Image 3 wants a driver class

**Symptom:** `Unable to resolve driver. Driver object must implement
Intervention\Image\Interfaces\DriverInterface.`

The monorepo pulls `intervention/image` 3, which resolves the driver from a
class. The version 2 string in `config/image.php` no longer works. Keep the env
variable and map it:

```php
'driver' => match (env('IMAGE_DRIVER', 'imagick')) {
    'gd' => \Intervention\Image\Drivers\Gd\Driver::class,
    default => \Intervention\Image\Drivers\Imagick\Driver::class,
},
```

### `artisan optimize` now compiles views

From Laravel 11, `optimize` includes `view:cache`. The deploy runs `optimize` on
the server, so anything that cannot compile now aborts a deployment that used to
succeed. Two things surface here:

1. **A view path that does not exist.** Many projects still list
   `resource_path('views')` in `config/view.php` while using `theme/views`.
   Remove the dead entry.
2. **Vendor views the theme cannot satisfy.** `view:cache` compiles every
   registered view, including package views the project overrides at runtime.
   A blog module view calling `<x-ui.card.image>` fails if the theme only has a
   monolithic `card.blade.php`. Add the missing sub-components in the theme's own
   idiom.

Run it locally before deploying, it is the same command the deploy runs:

```sh
php artisan optimize:clear && php artisan optimize
```

To see every failure at once instead of stopping at the first:

```sh
php artisan tinker --execute='
$factory = app("view"); $finder = $factory->getFinder(); $compiler = app("blade.compiler");
foreach (array_merge($finder->getPaths(), ...array_values($finder->getHints())) as $path) {
    if (! is_dir($path)) continue;
    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)) as $file) {
        if (! str_ends_with($file->getFilename(), ".blade.php")) continue;
        try { $compiler->compile($file->getRealPath()); }
        catch (Throwable $e) { echo $file->getRealPath(), "\n  ", $e->getMessage(), "\n"; }
    }
}'
```

Resolving `view` rather than `view.finder` matters: the namespace hints are
registered by a callback that only fires when `view` itself is resolved.

### One Alpine, not two

A theme that bundles its own Alpine and calls `Alpine.start()` ends up with two
instances once Livewire 3 ships its own. Point the `alpinejs` import at
Livewire's instance and let Livewire start it.

```ts
// theme/src/lib/alpine.ts
import { Alpine } from 'livewire'

export default Alpine
```

```ts
// theme/vite.config.mts
resolve: {
    alias: {
        'livewire': resolve(vendorDir, 'livewire/livewire/dist/livewire.esm.js'),
        'alpinejs': resolve(__dirname, 'src/lib/alpine.ts'),
    },
},
```

`app.ts` then registers its stores as before and ends with `Livewire.start()`
instead of `Alpine.start()`.

::: danger Register no Alpine plugins
Livewire 3's bundle already carries `persist`, `intersect`, `collapse` and
`focus`. Registering any of them again on the shared instance throws

```
Uncaught TypeError: Cannot redefine property: $persist
```

and Alpine stops wiring directives. Nothing looks broken: the page renders,
Alpine and Livewire are both on `window`, the stores exist. But every
`x-bind` on the page is silently dropped, so anything whose classes come from
a binding keeps only its static ones. A navbar that binds its background to
`$store.navigation` simply loses it.

Delete these lines and their imports when moving a v1 theme over:

```ts
Alpine.plugin(PersistPlugin)
Alpine.plugin(IntersectPlugin)
Alpine.plugin(CollapsePlugin)
```

Alpine's own directives keep working, so a smoke test will not catch this. The
cheapest check is to run the built bundle against a real page in jsdom and
assert on the element's `class` attribute after start.
:::

When the theme bundles Livewire this way, the layout must emit only the runtime
config, not the script tag, or a second Livewire loads:

```blade
{!! \Livewire\Mechanisms\FrontendAssets\FrontendAssets::styles() !!}
{!! \Livewire\Mechanisms\FrontendAssets\FrontendAssets::scriptConfig() !!}
```

`scriptConfig()` also suppresses auto-injection, so this is the one case where it
is correct. Use `scripts()` whenever the theme does **not** bundle Livewire
itself.

Verify in the rendered HTML that `livewire.js` is loaded exactly once.

### The theme build moved to `public/build`

The v2 theme package renders assets through Laravel's `@vite`, which reads
`public/build`. Projects still copying `dist/` into `public/assets` with
`rollup-plugin-copy` will throw `Vite manifest not found at:
public/build/manifest.json` on every page. Switch the theme to
`laravel-vite-plugin` with `publicDirectory: '../public'` (it needs vite 5) and
add `/public/build` to `.gitignore`.

### `envoy database:down` does not drop the local database

The task imports the dump into the existing schema. The dump drops and recreates
the tables it contains, but tables that exist only locally, such as those created
by v2 migrations you already ran, survive. The dump also restores the source
`migrations` table, so those migrations look pending again and re-running them
fails with `Base table or view already exists`.

Drop the schema first:

```sh
mysql -u root -e 'drop database <db>; create database <db> character set utf8mb4 collate utf8mb4_unicode_ci;'
./vendor/bin/envoy run database:down --target=live
php artisan migrate
```

From 2.22.3 on, `evolve:db:copy` empties the destination itself before it
imports, so a copy into an environment that has already been deployed and
migrated only needs the `migrate` afterwards. Before 2.22.3, empty the
destination schema by hand before copying.

### Verify against real data, not an old dump

Migrations that pass on a stale dump can still fail on production. Pull the
current database down before running them, and treat a green run there as the
actual check. The same stale-data trap explains most "the styling looks off
locally" reports: the local database is simply showing a different page than the
live site.

### `--from-file` on `evolve:env:setup` is project relative

The path is resolved through `base_path()`, so an absolute path is reported as
missing. Pass a path relative to the project root.

## Troubleshooting

### "Unable to locate file: vendor/racerfish/evolve/bin/Envoy.blade.php"

The Envoy import path changed in v2.0. Update your `Envoy.blade.php` to use `vendor/racerfish/evolve/core/bin/Envoy.blade.php`. See [Step 12](#step-12-update-envoy-import-path).

### Class not found errors

Run `composer dump-autoload` to regenerate the autoloader.

### Duplicate migration errors

Check for locally published migration files that conflict with package migrations. See [Step 11](#step-11-clean-up-published-migrations).

### Views not found

Clear the view cache: `php artisan view:clear`. If you have published views in `theme/views/vendor/`, verify the view namespace paths match (they haven't changed).

### Livewire component not found

If you see "Unable to find component" errors, check that the namespace was updated from `App\Http\Livewire` to `App\Livewire` and that files were moved accordingly.

### "Property not found" on wire:model

Ensure `legacy_model_binding => true` is set in `config/livewire.php`. See [Step 6](#step-6-add-livewire-3-configuration)

### "Call to undefined method LivewireManager::scripts()"

Your theme layout still uses `@livewireScripts` or `@livewireStyles`. See [Step 7](#step-7-migrate-frontend-template-livewire-directives).

### `composer update` stops at racerfish/evolve 2.0.2

Composer resolves `^2.0` against your current framework constraint. 2.1.0+ requires Laravel 12 (via `laravel/ai ^0.4`), so a project still on Laravel 11 is capped at 2.0.2. Run `composer why-not racerfish/evolve 2.1.1` to confirm. Complete [Step 3](#step-3-update-laravel-framework) to move to Laravel 12, then `composer update racerfish/evolve` will pick up 2.1.x.

### Backend search or filter inputs throw "Cannot read properties of undefined (reading 'find')"

This is the v2.0.x Alpine 2 vs. Livewire 3 version skew. Evolve 2.0.x ships Alpine 2 in the backend bundle, but the backend Blade views use Livewire 3's `@entangle`, which compiles to `window.Livewire.find(...).entangle(...)`. Alpine 2 evaluates `x-data` on `DOMContentLoaded`, before `window.Livewire` exists. The fix is to upgrade to Laravel 12 + Evolve 2.1.x, which moves the backend bundle to Alpine 3. See the warning at the top of this guide.
