---
url: /custom-modules/extending-evolve.md
---
# Extending Evolve

Thanks to some concepts in Laravel like the Dependency Injection or the Blade View Loader most parts of Evolve can easily be extended and overridden.

::: danger Disclaimer
**Be aware that Evolve updates can break your extensions.**
You are responsible for keeping your custom implementations in sync with changes inside the Evolve Core or any Evolve package.
:::

## Overriding Views

You can override Blade views from inside the package by copying them into your application's `resources/views/vendor/[namespace]` directory, or into `theme/views/vendor/[namespace]`, where the module publish tags put frontend views.

Using the blog module as an example, Laravel first checks whether a custom version of the view exists under `vendor/blog` in one of those locations before loading it from the package. Core panel views live in the `evolve` namespace. After every update, `php artisan evolve:view-overrides` lists the copies of core views in your project and the changes the originals received since; module views are not covered and have to be compared by hand.

Which views, blueprints, configs and overlays can be copied, and where each copy has to go, is listed in [Publishing and Overriding Package Files](/develop/publishing-and-overriding). Read more in the official Laravel docs: [Overriding Package Views](https://laravel.com/docs/master/packages#overriding-package-views)

::: warning
Make sure the relative view path inside the package is identical to the original package inside composer's `vendor` directory.
:::

## Extending Models and Controllers

> Models, Controllers or any other PHP class can easily be extended or completely swapped out using Laravel's powerful [Service Container](https://laravel.com/docs/master/container) concept.

Again, using the blog module as an example, if you want to extend or replace the Post model, you can create a custom version of the model in your projects `app/Models` directory (or anywhere you like) and extend the original.

```php [app/Models/Post.php]
<?php
namespace App\Models;

class Post extends \Racerfish\Blog\Models\Post {
    // custom logic
}
```

Now you need to tell Laravel's Service Container to use your custom class when resolving the Post model. You can do this with a [simple container binding](https://laravel.com/docs/master/container#binding-basics) or use our little `Extension` helper class.

```php [app/Providers/AppServiceProvider.php]
use Racerfish\Evolve\Services\Extension;

public function register()
{
    // Swap original class with our custom implementation
    Extension::swap([
        \Racerfish\Blog\Models\Post::class => \App\Models\Post::class,
    ]);
}
```

## Form Types&#x20;

Some forms need fixed fields and their own logic: a search profile with a budget range, an object inquiry that carries a reference number, a callback request with a normalised phone number. Instead of a separate controller per form, a module registers a **form type**. Editors pick the type when they create a form in the panel and keep every other setting of a built form: label, confirmation text, redirect, senders and recipients. The builder lists the type's fields read only.

Submissions of typed forms go through the same endpoint as built forms (`POST /api/form/{name}`). Spam protection (honeypot, reCAPTCHA, keyword filter on text fields), lead creation, file uploads, the tracking event and the notifications stay shared. The type only decides which fields exist, how the request is validated and how the values are normalised before they are stored.

### When to use a form type

Most forms are best built in the panel. Reach for a form type when at least one of these applies:

1. **The fields must not change.** Code or an external system depends on them, for example a contact sync that maps `budget_min` to a CRM attribute. An editor who renames or deletes the field in the builder would silently break it.
2. **The values need normalising or cross checks.** A phone number reduced to digits, a budget range where the minimum must stay below the maximum, a postcode checked against a list.
3. **The form needs computed values.** Marketing attributes for a CRM (`attributes()`) or extra placeholders for mail templates (`placeholders()`).
4. **The frontend needs more than the generic field list.** A custom layout, a range picker or a value prefilled from the page (`view()` and `prefill`).

If none of this applies, a built form is simpler and lets editors adjust it without a deploy.

### Building a type step by step

1. Create a class that implements `Racerfish\Evolve\Contracts\FormType` (contract and example below).
2. Register it in a service provider with `Evolve::registerFormType()`.
3. In the panel, open **Forms**, create a form and pick the type. Set the label, recipient, confirmation text and redirect as for any form.
4. Place the form on a page with the form block, or render it in a template with `<x-form :form="$form" />`.
5. Submit it once and check the lead in **Leads**: columns filled, extra values in the payload, notification sent.

### The contract

A form type implements `Racerfish\Evolve\Contracts\FormType`:

| Method | Purpose |
|---|---|
| `key(): string` | Stored in `evolve_forms.type`. Never change it once forms use it. |
| `label(): string` | Shown in the type select and as a badge in the form list. |
| `fields(): array` | Field definitions in the format of the builder (see below). They render the generic frontend form, name the attributes in validation errors and show in the builder. |
| `rules(Form $form, Request $request): array` | Validation rules. The shared spam rules are added on top. |
| `prepare(Form $form, Request $request): array` | Normalised values of a validated submission. Keys that match a lead column land in that column, all other keys in the lead's `payload`. |
| `attributes(Lead $lead): array` | Computed marketing attributes for a contact sync, for example `BUDGET_MIN`. Return an empty array when there are none. |
| `placeholders(Lead $lead): array` | Extra values for mail templates, for example the labels of selected options. Return an empty array when there are none. |
| `view(): ?string` | A Blade view that renders the fields on the frontend, or `null` for the generic rendering of `fields()`. |

```php [modules/acme/src/FormTypes/CallbackRequestFormType.php]
<?php

namespace Acme\FormTypes;

use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Racerfish\Evolve\Contracts\FormType;
use Racerfish\Evolve\Models\Form;
use Racerfish\Evolve\Models\Lead;

class CallbackRequestFormType implements FormType
{
    public const TIMES = [
        'morning' => 'Morning (8 to 12)',
        'afternoon' => 'Afternoon (13 to 17)',
    ];

    public function key(): string
    {
        return 'callback-request';
    }

    public function label(): string
    {
        return 'Callback request';
    }

    public function fields(): array
    {
        return [
            ['type' => 'text', 'subtype' => 'text', 'name' => 'firstname', 'label' => __('First name'), 'required' => true],
            ['type' => 'text', 'subtype' => 'text', 'name' => 'phone', 'label' => __('Phone'), 'required' => true],
            [
                'type' => 'select',
                'subtype' => 'select',
                'name' => 'preferred_time',
                'label' => __('When may we call?'),
                'required' => true,
                'values' => collect(self::TIMES)
                    ->map(fn (string $label, string $value) => ['value' => $value, 'label' => $label])
                    ->values()
                    ->all(),
            ],
            ['type' => 'checkbox', 'subtype' => 'checkbox', 'name' => 'accepts_marketing', 'label' => __('Send me the newsletter.')],
        ];
    }

    public function rules(Form $form, Request $request): array
    {
        return [
            'firstname' => ['required', 'string', 'max:100'],
            'phone' => ['required', 'string', 'regex:/^[0-9 +()\/-]{7,30}$/'],
            'preferred_time' => ['required', Rule::in(array_keys(self::TIMES))],
        ];
    }

    public function prepare(Form $form, Request $request): array
    {
        return [
            'firstname' => trim($request->input('firstname')),
            'phone' => preg_replace('/[^0-9+]/', '', $request->input('phone')),
            'accepts_marketing' => $request->boolean('accepts_marketing'),
            'preferred_time' => $request->input('preferred_time'),
        ];
    }

    public function attributes(Lead $lead): array
    {
        return [
            'CALLBACK_TIME' => $lead->payload['preferred_time'] ?? null,
        ];
    }

    public function placeholders(Lead $lead): array
    {
        return [
            'preferred_time_label' => self::TIMES[$lead->payload['preferred_time'] ?? ''] ?? null,
        ];
    }

    public function view(): ?string
    {
        return null;
    }
}
```

In this example `firstname`, `phone` and `accepts_marketing` are lead columns, so they land there. `preferred_time` is not a column and ends up in the payload as `['preferred_time' => 'morning']`.

The Evolve repository ships the same type as a working demo: `workbench/app/Forms/CallbackRequestFormType.php`, registered in the workbench and used by `core/tests/Feature/FormTypeTest.php`.

### Field definitions

`fields()` returns the same arrays the form builder stores, so everything the builder can produce works here too.

| Key | Meaning |
|---|---|
| `type` and `subtype` | The element: `text/text`, `text/email`, `textarea/textarea`, `select/select`, `checkbox-group/checkbox-group`, `radio-group/radio-group`, `checkbox/checkbox`, `date/date`, `date/datetime-local`, `number/number`, `range/range`, `range/multi`, `file/file`, `hidden/hidden`, `paragraph/paragraph`. |
| `name` | The request key and, after `prepare()`, the lead column or payload key. |
| `label` | Shown on the frontend and used as the attribute name in validation errors. |
| `required` | Marks the field as required on the frontend. The actual check belongs in `rules()`. |
| `placeholder`, `description`, `default` | Optional texts and a default value. |
| `values` | Options of selects, checkbox groups and radio groups, as a list of `['value' => ..., 'label' => ...]`. |
| `min`, `max`, `step` | Bounds of number and range fields. |
| `carboncopy` | On an email field: the submitted address also receives the new lead notification. |

The field definitions only describe the form. Unlike a built form, a typed form does not derive validation from them: whatever `rules()` does not check is not checked.

### What the pipeline does with a type

A submission to a typed form runs through these steps, in this order:

1. **Validation.** The rules from `rules()` are merged with the shared rules: the honeypot (`fax_number`, `user_identification`), reCAPTCHA when it is configured, a plain `email` rule on the `email` key, and the keyword filter on every text and textarea field of `fields()`. Error messages name the fields by their `label`. AJAX submissions receive a 422 JSON response, regular submissions are redirected back to the form with the errors.
2. **Normalisation.** `prepare()` returns the values to store. Keys that are lead columns go into their column: `salutation`, `firstname`, `lastname`, `company`, `street`, `street_no`, `zip`, `city`, `country`, `email`, `phone`, `remarks`, `comments` and `accepts_marketing`. Every other key goes into `payload`. A `payload` key in the returned array is merged in as well. `path` is taken from the request and `form_id` is set by Evolve, a type does not need to return them.
3. **Consent snapshot.** When `accepts_marketing` is true, the lead stores the label of the `accepts_marketing` field from `fields()`, see [Consent and the lead pipeline](#consent-and-the-lead-pipeline).
4. **Storing.** The lead is saved. Files of `file` fields in `fields()` are attached to it. `evolve.forms.allow-files` applies as for built forms, but the size limit `evolve.forms.max-file-size` is only added to built forms. Validate files in `rules()` yourself, for example `'documents.*' => ['file', 'max:10000']`.
5. **`LeadCreated`** fires.
6. **Notifications** go out as configured on the form, including the carbon copies of email fields with `carboncopy`.
7. **Tracking.** The server side `generate_lead` event is recorded and the visitor sees the confirmation text or is redirected.

`prepare()` cannot write anything but the columns listed above. The consent snapshot and the sync columns are protected and ignored when a type returns them.

### Registering a type

Register the type in the module's service provider. The class resolves through the container on first use, so `register()` and `boot()` both work, and the type may take constructor dependencies.

```php [modules/acme/src/AcmeServiceProvider.php]
use Acme\FormTypes\CallbackRequestFormType;
use Racerfish\Evolve\Facades\Evolve;

public function boot(): void
{
    Evolve::registerFormType(CallbackRequestFormType::class);
}
```

Registering a class that does not implement the contract throws an `InvalidArgumentException` right away. `Evolve::getFormTypes()` returns all registered types keyed by `key()`, `Evolve::getFormType($key)` a single one or `null`.

As soon as one type is registered, the create drawer under **Forms** shows a type select. The type is fixed once the form exists, because it decides what a lead of that form contains. On a form model, `$form->formType()` returns the type instance (or `null` for built forms and unknown types) and `$form->isTyped()` tells whether the form has a type other than `builder`.

### Changing or removing a type

1. **Fields.** Change `fields()`, `rules()` and `prepare()` freely in a deploy. Existing leads keep what they stored. Keep a payload key when you rename it in the code, or older leads read differently from new ones.
2. **The key.** Never rename it. Forms store it, and a form with an unknown key falls back to its stored fields.
3. **Removing the module.** Forms of the type stay. They render the fields stored in `evolve_forms.data` (empty for forms created as typed), the panel shows the type as not installed, and the form settings can still be saved. Delete those forms or switch the page to another form before removing the module.

### Rendering on the frontend

The `cms.form` block and `<x-form :form="$form" />` render typed forms like built ones: the fields come from `fields()`, so copies of the theme's `form` component keep working. Two additions in the starter theme:

1. When `view()` returns a view name, the component includes it in place of the generic fields. The view receives `$form`, `$fields` (the definitions with prefilled defaults applied), `$inline` and `$prefill`. The CSRF token, honeypot, reCAPTCHA and submit button stay in the component.
2. The `prefill` prop sets field defaults by name, for example a reference number taken from the page: `<x-form :form="$form" :prefill="['reference' => $object->reference]" />`.

A custom view only renders inputs. The starter theme's field components (`x-form-elements.text`, `.select`, `.radio-group`, `.checkbox` and so on) take a field definition, so a view can arrange the type's own fields and keep defaults, old input and prefill working:

```blade [modules/acme/resources/views/form-types/callback-request.blade.php]
@php($field = fn (string $name) => collect($fields)->firstWhere('name', $name))

<div class="grid gap-4 md:grid-cols-2">
    <div class="form-group">
        <label for="firstname">{{ $field('firstname')['label'] }}</label>
        <x-form-elements.text :input="$field('firstname')" />
    </div>

    <div class="form-group">
        <label for="phone">{{ $field('phone')['label'] }}</label>
        <x-form-elements.text :input="$field('phone')" />
    </div>
</div>

<div class="form-group">
    <x-form-elements.radio-group :input="$field('preferred_time')" />
</div>
```

Return the view name from `view()`, for example `'acme::form-types.callback-request'`, after loading the module's views with `loadViewsFrom()`. A view that renders its own inputs instead of the theme components must name them exactly like the fields.

### Consent and the lead pipeline

The `accepts_marketing` checkbox is the marketing consent, on built and typed forms alike (the email module's [Marketing card](/panel/forms-and-leads#marketing-contact-sync) can point the consent at another checkbox of the form). When a visitor ticks it, the lead stores the label exactly as shown (`consent_text`), the time (`consent_given_at`) and a SHA-256 hash of the label (`consent_hash`). The hash changes whenever the label changes, which is the moment the consent version should change. For a typed form the label comes from `fields()`, so a translated label (`__()`) stores the text in the language the visitor saw. None of these columns can be set through the request.

The snapshot is only taken from the consent field: `accepts_marketing`, or the checkbox stored as `consent_field` in the form's `sync` settings (`Form::consentField()`). Any other checkbox is stored like any other value and carries no consent. A ticked consent checkbox without a label stores its name as the text. When the form uses double opt in (`Form::usesDoubleOptIn()`), the lead keeps text and hash but `accepts_marketing` stays `false` and `consent_given_at` empty until the visitor confirms; `Lead::awaitsDoubleOptIn()` tells that state apart.

After the lead and its attachments are stored, Evolve fires `Racerfish\Evolve\Events\LeadCreated` (with `$event->locale`, the language of the submission, since a queued listener runs without it), before the notifications go out (`NotificationsSending`). It fires for built forms too. A listener that talks to an external service should implement `ShouldQueue`, so a slow or failing provider never reaches the visitor. The lead columns `synced_at`, `sync_error` and `contact_id` are reserved for such a sync and are not fillable, write them with `forceFill()`.

```php
use Illuminate\Contracts\Queue\ShouldQueue;
use Racerfish\Evolve\Events\LeadCreated;

class SendLeadToCrm implements ShouldQueue
{
    public function handle(LeadCreated $event): void
    {
        $lead = $event->lead;
        $computed = $lead->form?->formType()?->attributes($lead) ?? [];

        // Send $lead and $computed to the CRM, then record the result:
        $lead->forceFill(['synced_at' => now(), 'sync_error' => null])->save();
    }
}
```

### Placeholders for mail templates&#x20;

A [mail template](/panel/mail-templates) fills {{ lead.\* }}, {{ form.\* }} and {{ site.\* }} for every form. `placeholders()` adds values only a type can compute, under the key it returns, without prefix:

```php
public function placeholders(Lead $lead): array
{
    return [
        'preferred_time_label' => self::TIMES[$lead->payload['preferred_time'] ?? ''] ?? null,
        'profile' => ['url' => URL::signedRoute('acme.profile', $lead)],
    ];
}
```

The template then uses {{ preferred_time_label }} and {{ profile.url }}: nested keys become dotted names, lists are joined with commas, booleans become yes or no in the mail's language. Keys of the standard set win over a type key of the same name, so stay clear of `lead.`, `form.` and `site.`. The method runs in the language of the lead (`App::getLocale()` is set), so `__()` translates. An exception is reported and the type's placeholders stay empty; the mail still goes out. A placeholder that is a whole link (a button URL of {{ profile.url }}) is only used when it is an `http`, `https`, `mailto` or `tel` link or a path.

### Testing a type

Post to the shared endpoint in a feature test. The honeypot needs an empty `fax_number` and an encrypted timestamp that is old enough:

```php
use Illuminate\Support\Facades\Crypt;
use Racerfish\Evolve\Models\Form;
use Racerfish\Evolve\Models\Lead;

public function test_a_callback_request_is_stored_normalised(): void
{
    Form::create(['name' => 'callback', 'label' => 'Callback', 'type' => 'callback-request']);

    $this->postJson('/api/form/callback', [
        'firstname' => ' Jane ',
        'phone' => '+41 79 123 45 67',
        'preferred_time' => 'morning',
        'fax_number' => '',
        'user_identification' => Crypt::encrypt(time() - 10),
    ])->assertOk();

    $lead = Lead::sole();

    $this->assertSame('+41791234567', $lead->phone);
    $this->assertSame(['preferred_time' => 'morning'], $lead->payload);
}
```

## Mail Templates for Form Mails&#x20;

Forms pick a confirmation and a notification template in their settings (`evolve_forms.confirmation_template_id`, `notification_template_id`). The notifications ask the contract `Racerfish\Evolve\Contracts\LeadMailTemplates`, which the core binds to its own `Racerfish\Evolve\MailTemplates\FormMailTemplates` since 2.22 (until 2.21 the email module bound it):

| Method | Purpose |
|---|---|
| `options(): array` | `id => name` for the two selects in the form settings. |
| `mail(int $templateId, Lead $lead): ?Mailable` | The rendered mail in the lead's language, `null` when the template is gone. |

`NewLeadReceived` and `LeadReceivedConfirmation` ask the contract and return the `Mailable` from `toMail()` when there is one, so routing stays with the notification: the recipient, the skipped notification without recipient address, sender, reply-to, carbon copies, the form's attachments and the `NotificationSent` events. Without a template id, with an unknown one, or when rendering throws (reported, never shown to the visitor), the Markdown views `evolve::email.lead.customerMail` and `adminMail` render as before. A project that wants its own mail logic binds its own implementation of the contract in its service provider's `register()`; the core uses `bindIf`, so the project's binding wins.

`Racerfish\Evolve\MailTemplates\TemplateRenderer` renders a `MailTemplate` for a lead and returns a `RenderedMail` (subject, HTML, plain text, locale, unknown placeholders); `TemplatedMail` wraps it for the mailer. Use them to send a template from your own code:

```php
use Illuminate\Support\Facades\Mail;
use Racerfish\Evolve\MailTemplates\TemplatedMail;
use Racerfish\Evolve\MailTemplates\TemplateRenderer;
use Racerfish\Evolve\Models\MailTemplate;

$template = MailTemplate::firstWhere('name', 'Profile reminder');
$rendered = app(TemplateRenderer::class)->render($template, $lead);

Mail::to($lead->email)->send(new TemplatedMail($rendered));
```

Campaigns and templates render through one path, `Racerfish\Evolve\Services\EmailDocumentRenderer`: the layout `evolve::email.document` with the block views `evolve::email.blocks.*`. `evolve::email.template` and the email module's `email::render.campaign` only include the document, so a project can override the layout for one of them (publish tag `evolve.mail-views`, copies land in `theme/views/vendor/evolve/email`). The old names `Racerfish\Email\Models\MailTemplate`, `Racerfish\Email\Templates\*` and `Racerfish\Email\Services\EmailDocumentRenderer` still resolve as deprecated subclasses until 3.0. A block view receives `$fields` (placeholders already filled in templates), `$theme`, `$font`, `$width`, `$mediaUrl`, and in templates `$lead`, `$form` and `$summary` (the rows of the lead summary block). Placeholders are filled in the field values before the view escapes them, so a block view must keep echoing with {{ }}.

The lead stores the language it was submitted in (`evolve_leads.locale`, taken from the session's locale on the submit request, else the app locale), so a template renders in it even when the mail goes out later.

## Form Settings Sections&#x20;

A module can add a card to the settings drawer of every form, the way the email module adds its [Marketing card](/panel/forms-and-leads#marketing-contact-sync). Implement `Racerfish\Evolve\Contracts\FormSettingsSection` and register the class in the module's service provider:

```php
use Racerfish\Evolve\Facades\Evolve;

Evolve::registerFormSettingsSection(CrmFormSection::class);
```

| Method | Purpose |
|---|---|
| `key(): string` | Unique key. The editor state lives under `sections.{key}` in the form manager. |
| `view(): string` | Blade view of the card. It receives `$form`, `$state` (the `wire:model` prefix, so inputs bind `wire:model="{{ $state }}.enabled"`) and `$values` (the current state). No Livewire component of its own is needed. |
| `state(Form $form): array` | The editor state, read when the drawer opens. |
| `rules(Form $form): array` | Validation rules, keys relative to the state (`'enabled'`). |
| `save(Form $form, array $state): void` | Writes the validated state into the form right before it is saved, for example into a JSON column. |

The email module stores its settings in `evolve_forms.sync`. A project that copied the form list view (`form.index`) has to add the section loop, see `php artisan evolve:view-overrides`.

## Activity Log Subjects&#x20;

The activity log (`/evolve/activity`) names each entry's subject with a translated type label and the record's current title, linked to its edit screen ("Page: Kitchen Sink"), and filters by a short alias (`?subjectType=page`). Core registers its own models and the Shopify module its products and stores. A module or app whose models use `LogsActivity` registers theirs with `ActivitySubject`:

```php
use Racerfish\Evolve\Support\ActivitySubject;

Evolve::registerModule('guestbook', [
    // ...
    'activity' => [
        ActivitySubject::make('guestbook-entry', Entry::class, 'guestbook::panel.entry')
            ->filter('guestbook::panel.entries')
            ->url(fn (Entry $entry) => route('guestbook.edit', $entry)),
    ],
]);
```

An app without a module calls `Evolve::registerActivitySubjects([...])` with the same objects, for example in `AppServiceProvider::boot()`; an app registration with a core alias replaces the core one.

| Method | Purpose |
|---|---|
| `make($alias, $model, $label)` | Alias for the filter URL, the model class, the singular label (translation key or plain text). |
| `->filter($label)` | Adds a filter tab above the log with this plural label. Without it the type is filterable by URL only. |
| `->url(fn ($record) => ...)` | Link to the record's edit screen. Without it, models implementing `Searchable` link to `getSearchableAdminUrl()`; others show no link. |

The title comes from `getSearchableTitle()` when the model implements `Searchable`, otherwise from the name stored at log time. A deleted record shows "Label #id" without a link, and a failing URL resolver shows the entry without a link instead of breaking the log. Unregistered types fall back to the class basename, never the full class name. Subclasses of a registered model (an app's `User extends Racerfish\Evolve\Models\User`) take the parent's label. The alias lives only in the activity log; Evolve does not register a Laravel morph map, so `*_type` columns keep their class names. Old bookmarks with class names in `subjectType` still filter and switch to the alias.

## Structured Data&#x20;

A module that needs its own JSON-LD block merges a `JsonLdTag` into the head instead of writing a `<script>` by hand:

```php
use Racerfish\Evolve\DTO\Head\JsonLdTag;

JsonLdTag::make([
    '@type' => 'Event',
    'name' => $event->title,
    'startDate' => $event->starts_at?->toIso8601String(),
    'organizer' => ['@id' => url('/#organization')],
], 'event');
```

The tag adds `@context`, drops empty values and escapes safely; the identifier decides whether a second tag replaces the first (same identifier) or sits next to it. Point at the site-wide Organization with `@id` `{base}/#organization` rather than repeating the company data; it exists only when the legal name is set in Settings > Company. Records that implement `Contracts\StructuredData` get their block from the theme automatically. Details on [Head and SEO internals](/internals/head-and-seo#structured-data).

## Mail Addresses in Modules&#x20;

Since 2.18 Evolve never mails the sender address as a recipient. Modules follow the same rule:

| Purpose | Read |
|---|---|
| Internal notifications (new leads, registrations, requests) | `config('evolve.mail.notification-address')`, or the form's admin address first when there is a form |
| Technical alerts (sync failures, quota warnings) | `Racerfish\Evolve\Health\AlertNotifiable::address()`, which resolves `evolve.mail.alert-address` and falls back to the health notification address |
| Reply-To of mails to visitors | `config('evolve.mail.reply-to')`, unset means the sender |

All three are edited on the Mail tab of the settings panel (settings store) with `.env` fallbacks, see [Mail](/getting-started/mail#addresses). When none is set, skip the mail and log a warning; do not fall back to `mail.from.address`, which is often a no-reply mailbox. Recipients built on `Racerfish\Evolve\Recipients\GenericRecipient` already skip and log when their address is empty.

## Health Checks in Modules&#x20;

A module brings its own health checks. Register them in `bootModule()`, so they only exist while the module is on, and put them behind a toggle when they call an external service:

```php
use Spatie\Health\Facades\Health;

protected function bootModule(): void
{
    if (config('shop.health-check', false)) {
        Health::checks([ShopApiCheck::new()->hourly()]);
    }
}
```

`Health::checks()` adds to the checks the core registers. Each check needs a unique name (the class basename without `Check`, or `->name()`), Spatie throws on a duplicate. The checks show on the [health dashboard](/site/health-checks) and in the Oh Dear feed like the core checks. The email, Shopify and Scout24 modules work the same way.
