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.
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. Read more in the official Laravel docs: 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 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
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 or use our little Extension helper class.
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 2.17
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:
- The fields must not change. Code or an external system depends on them, for example a contact sync that maps
budget_minto a CRM attribute. An editor who renames or deletes the field in the builder would silently break it. - 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.
- The form needs computed values. Marketing attributes for a CRM (
attributes()) or extra placeholders for mail templates (placeholders()). - The frontend needs more than the generic field list. A custom layout, a range picker or a value prefilled from the page (
view()andprefill).
If none of this applies, a built form is simpler and lets editors adjust it without a deploy.
Building a type step by step
- Create a class that implements
Racerfish\Evolve\Contracts\FormType(contract and example below). - Register it in a service provider with
Evolve::registerFormType(). - In the panel, open Forms, create a form and pick the type. Set the label, recipient, confirmation text and redirect as for any form.
- Place the form on a page with the form block, or render it in a template with
<x-form :form="$form" />. - 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
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:
- Validation. The rules from
rules()are merged with the shared rules: the honeypot (fax_number,user_identification), reCAPTCHA when it is configured, a plainemailrule on theemailkey, and the keyword filter on every text and textarea field offields(). Error messages name the fields by theirlabel. AJAX submissions receive a 422 JSON response, regular submissions are redirected back to the form with the errors. - 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,commentsandaccepts_marketing. Every other key goes intopayload. Apayloadkey in the returned array is merged in as well.pathis taken from the request andform_idis set by Evolve, a type does not need to return them. - Consent snapshot. When
accepts_marketingis true, the lead stores the label of theaccepts_marketingfield fromfields(), see Consent and the lead pipeline. - Storing. The lead is saved. Files of
filefields infields()are attached to it.evolve.forms.allow-filesapplies as for built forms, but the size limitevolve.forms.max-file-sizeis only added to built forms. Validate files inrules()yourself, for example'documents.*' => ['file', 'max:10000']. LeadCreatedfires.- Notifications go out as configured on the form, including the carbon copies of email fields with
carboncopy. - Tracking. The server side
generate_leadevent 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.
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
- Fields. Change
fields(),rules()andprepare()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. - The key. Never rename it. Forms store it, and a form with an unknown key falls back to its stored fields.
- 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:
- 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),$inlineand$prefill. The CSRF token, honeypot, reCAPTCHA and submit button stay in the component. - The
prefillprop 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:
@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 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().
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 2.19
A mail template fills {{ lead.* }}, {{ form.* }} and {{ site.* }} for every form. placeholders() adds values only a type can compute, under the key it returns, without prefix:
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:
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 2.19
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:
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 2.17
A module can add a card to the settings drawer of every form, the way the email module adds its Marketing card. Implement Racerfish\Evolve\Contracts\FormSettingsSection and register the class in the module's service provider:
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=".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 2.18
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:
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 2.18
A module that needs its own JSON-LD block merges a JsonLdTag into the head instead of writing a <script> by hand:
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.
Mail Addresses in Modules 2.18
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. 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 2.21
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:
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 and in the Oh Dear feed like the core checks. The email, Shopify and Scout24 modules work the same way.