d3creative / statamic-canary
Canary by D3 Creative - checks a Statamic site's form emails and integrations still work, without saving a submission or emailing the client.
Package info
github.com/d3creativeuk/statamic-canary
Type:statamic-addon
pkg:composer/d3creative/statamic-canary
Requires
- php: ^8.3
- statamic/cms: ^6.0
Requires (Dev)
- orchestra/testbench: ^10.8 || ^11.0
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Check a Statamic site's form emails still send, without saving a submission or emailing the client.
The usual way to check a live form is to submit it. That saves a submission, signs the address up to newsletters, creates a lead in the CRM and emails the client's staff, who then ask what happened.
Canary sends a form's real emails (its own template, subject, from address and mailer) to one test address, through the real queue. It doesn't save a submission or run the form's listeners, so newsletter and CRM integrations never see it, and it never changes the form itself.
php artisan canary:forms --to=you@example.com
If the emails arrive, the templates, sender, mail provider and queue worker are all working.
Canary can also check a form's integrations, such as MailerLite or ActiveCampaign, without adding anyone to them. See Checking integrations.
When a form's notifications come from somewhere else, such as a CRM automation, --submit sends a real submission from a test address instead, through the form's validation, listeners and queue, and checks it arrived. See Sending real submissions.
Installation
composer require d3creative/statamic-canary
Requires Statamic 6 and PHP 8.3+.
Usage
# Pick forms from a list, and optionally save them for next time php artisan canary:forms --to=you@example.com # Test the contact and quote forms php artisan canary:forms contact quote --to=you@example.com # Choose again, ignoring the saved forms php artisan canary:forms --pick # Send as the French site on a multisite php artisan canary:forms contact --site=fr # List everything that can act on a form submission, without testing anything php artisan canary:forms --scan # Check everything and run the API checks, but send nothing php artisan canary:forms contact --dry-run # Send straight away instead of through the queue, so a mailer error fails the run php artisan canary:forms contact --now # List the real recipients Canary checked but didn't email php artisan canary:forms contact -v # Also add the test address to each integration, then delete it php artisan canary:forms newsletter --subscribe # Send a real submission through the form, its listeners and the queue, from a test address php artisan canary:forms contact --submit
The examples without --to assume CANARY_TO is set. See Setting defaults.
forms |
Handles of the forms to test. |
--to |
The address that receives every test email. |
--site |
Handle of the site to send as, on a multisite. Defaults to the default site. |
--pick |
Choose forms from the list, even if some are saved or configured. |
--scan |
List the site's form listeners, anything that could email someone else, each form's emails and failed jobs, without testing anything. See Scanning a site. |
--dry-run |
Build and check every email and run the API checks, but send nothing. See Dry run. |
--now |
Send the emails straight away instead of through the queue, so a mailer or sender error fails the run. This doesn't test a queue worker. |
-v |
List the real recipients of each email, including any cc and bcc, which Canary checks but never emails. |
--subscribe |
Also add the test address through each integration, then delete it. See Subscribing a test contact. |
Picking forms from a list
If you run canary:forms with no handles, Canary lists every form on the site and you tick the ones to test. Each form shows what Canary will test in it:
contact - notification email
quote - notification and auto-reply emails
newsletter - auto-reply email, MailerLite
old_form - no emails or integrations found
A notification goes to a fixed address, usually your own inbox. An auto-reply goes to the visitor, through a field such as {{ email }}. Canary sends both kinds to your test address.
Forms with nothing to test are listed last. Canary never tests every form on its own: many sites have test forms you won't want to send.
After you pick, Canary asks whether to save your choice. If you say yes, later runs test the saved forms without showing the list, including non-interactive runs such as deploy scripts. Run canary:forms --pick to choose again.
Saved forms are stored in resources/addons/statamic-canary.yaml. Save them on your local copy and commit the file, so every environment tests the same forms.
Setting defaults
To set the test address, publish the config:
php artisan vendor:publish --tag=canary-config
Then add it to .env:
CANARY_TO=you@example.com
Now php artisan canary:forms needs no options at all.
The other settings, all optional:
.env |
Default | What it does |
|---|---|---|
CANARY_FROM_NAME |
D3 Creative |
Named in every test message: "Test submission from D3 Creative. Please ignore." Leave it empty for "Test submission. Please ignore." |
CANARY_SUBMIT |
none | The forms --submit may send, comma-separated, such as contact,quote. See Sending real submissions. |
CANARY_DELETE_AFTER |
15 |
Minutes after a --submit run before each test contact is deleted. |
You can also list the forms in config/canary.php instead of saving them from the list:
'forms' => ['contact', 'quote'],
Canary uses the first of these that gives it any forms: handles passed as arguments, then canary.forms, then the saved forms, then the list. With none of them, a non-interactive run (a deploy script, or --no-interaction) fails, because it can't show the list.
What you'll get
For each form, Canary builds an unsaved submission filled with sample values from the form's blueprint, then sends each of the form's emails with these changes:
tois your test address.- The subject starts with
TEST:. ccandbccare removed.
Everything else, including the template, from, reply_to and mailer, is unchanged. Email fields are filled with your test address, so automatic replies to the visitor come back to you too. Text fields get Test, so a subject like {{ full_name }} submitted the contact form. arrives as "TEST: Test submitted the contact form."
These changes are made to a copy of the form's settings in memory, never to the form itself. See Canary doesn't change your site.
Before anything is sent, Canary checks each email:
- The template renders. A missing or broken template fails the form, and none of its emails are sent.
- The real recipients are valid. Canary reads each email's real
to,ccandbccbefore replacing them. An emptyto, or any invalid address, fails, as a missing setting or routing rule would stop the real email too. An address atexample.com,example.orgorexample.net, including Laravel's defaulthello@example.comas the from address, gets a warning, as it usually means a setting fell back to its default. - Its mailer can be created. Canary creates each email's mailer, or the default one, without sending anything. A mailer whose transport package was removed, or a
MAIL_MAILERwith a typo, fails here instead of later in the queue worker. - Each email is sent once. Emails that only differ by recipient, such as one per staff routing rule, would all look the same at your test address, so Canary sends one and says how many it covers.
Checkboxes and multi-selects get every option, so routing rules that match on them all fire.
A run starts by naming the queue connection and mailer, then reports each form's emails and integration checks:
Scanning a site
--scan is for a first look at a site you don't know. It tests nothing: no email is built or sent, and no listener or API check runs. It doesn't need --to. It lists:
- Every form listener, registered or not: the forms it handles, the services its code names, whether it builds emails in code, and which Canary methods it has. Validators, package listeners and closures are labelled.
- Anything that could email someone else, from What could still email someone else: a custom
send_email_job, a listener on Laravel'sMessageSendingevent, or a globalmail.toaddress. A closure is named by the file it's in, so a package's hook, such as Laravel Nightwatch's, can be told apart from the site's own. - Each form's emails, with any
cc,bccor mailer of their own, and the listeners that handle the form. - Failed jobs waiting in the queue. Failed form emails are picked out with the date they failed, as each is an enquiry that never arrived. Other failed jobs, such as cache warming, are only counted.
It only reports, so it always exits 0. On an unfamiliar site, run --scan, then --dry-run, then a real run.
Dry run
--dry-run does everything up to sending. Canary builds each form's emails, renders their templates, checks the real recipients, and checks each integration, including its API check. Then it reports what it would have sent, instead of sending it:
INFO 1 email(s) for contact would go to you@example.com instead of hello@yoursite.com.
No email reaches a mailer or the queue, and no listener runs. API checks still run, as a revoked key or deleted list is one of the things worth catching, and they only read. The run ends by saying sending and the queue weren't tested, so a dry run can't be mistaken for a full pass. It can't be combined with --now or --subscribe.
Use it for a first run on a site you don't know, before anything is sent.
Exit codes
The command exits non-zero if a form doesn't exist, an email fails its checks or fails to dispatch, or an integration check fails, so a deploy script can stop.
It also exits non-zero if the run tests nothing at all, because a run that sends nothing proves nothing. A form with no emails whose only integrations have no API check counts as testing nothing, as Canary found the integration but ran nothing against it. A form with nothing to test alongside others shows a warning but doesn't fail the run. Every run ends with a summary, such as "Tested 4 forms: 3 emails dispatched, 1 integration check passed."
When the emails go through a real queue, the summary reminds you to check php artisan queue:failed if one doesn't arrive, since Canary only sees them dispatched. --now sends them during the run instead, so a mailer error fails it.
Canary names the queue connection and queue the emails go on, so you know which worker has to run them. If the connection is sync, it warns you instead: the templates and sender are tested, but no queue worker is.
It also names the mailer, and the form's own mailer when an email sets one. For smtp it adds the server, such as 127.0.0.1:2525, so you can tell a mail provider from a local catcher like Herd's. If the mailer is log or array, Canary warns you that nothing is delivered.
Emails built in code
Some sites don't set a form's emails in its settings. A listener builds them when the form is submitted, for example to choose staff recipients from the interests a visitor ticked:
$event->submission->form()->email($emails);
Canary can't see those emails without running the listener, and running it would also do everything else the listener does, such as adding a lead to a CRM. So the listener tells Canary what it would send, with canaryForms() and canaryEmails(). Build the emails in one method, used by both the real path and Canary's, so the two can't drift apart:
<?php namespace App\Listeners; use Statamic\Contracts\Forms\Submission; use Statamic\Events\SubmissionCreated; class HandleFormSubmissions { public function handle(SubmissionCreated $event): void { $form = $event->submission->form(); if ($form->handle() === 'enquiry') { $form->email($this->enquiryEmails($event->submission)); } } public function canaryForms(): array { return ['enquiry' => ['email', 'interests']]; } public function canaryEmails(string $form, Submission $submission): array { return $form === 'enquiry' ? $this->enquiryEmails($submission) : []; } private function enquiryEmails(Submission $submission): array { $staff = collect(config('enquiries.routing')) ->filter(fn ($to, $interest) => in_array($interest, (array) $submission->get('interests'))) ->map(fn ($to) => ['to' => $to, 'subject' => 'New enquiry', 'html' => 'emails/enquiry']); return [ ...$staff->values(), ['to' => $submission->get('email'), 'subject' => 'Thanks for your enquiry', 'html' => 'emails/enquiry-reply'], ]; } }
canaryEmails() returns emails in the same shape as a form's email settings, and Canary tests them exactly like emails from the settings: each one goes to your test address with a TEST: subject, after the checks above. When it returns any emails, they replace the form's own settings, as they would on a real submission. If the listener adds to the settings instead, return both.
- It must only build the emails. Don't send, save or call a service from it. Canary passes a submission that can't be saved, and blocks any request through Laravel's HTTP client that isn't a GET.
- A listener that only builds emails isn't listed as an integration. Add
canaryName()andcanaryCheck()if it also sends to a service. - Links built from a submission's ID won't work in the test emails, because the submission is never saved.
Without canaryEmails(), Canary doesn't report the form as having nothing to test. When it can tell which forms a listener handles, the list shows enquiry - emails built in App\Listeners\HandleFormSubmissions, add canaryEmails(). When it can't, a form with nothing in its settings shows nothing in the form's settings, App\Listeners\HandleFormSubmissions may handle it. The run warns the same way.
Checking integrations
Newsletter and CRM integrations are usually listeners on FormSubmitted. A listener decides which forms it handles in its own code, so Canary can't tell from outside which forms reach MailerLite. Running the listener to find out would add a real contact.
For the full check, a listener tells Canary about itself through a few plain methods. Canary looks for them by name, so the listener never depends on Canary, and removing Canary never breaks it. Without them, Canary reads the listener's code instead and can check less. See Without the methods.
| Method | What it does |
|---|---|
canaryForms(): array |
Each form the listener handles, and the fields it reads: ['contact' => ['email']]. |
canaryName(): string |
The name shown in the list and the output, such as MailerLite. Defaults to the class name. |
canaryCheck(string $form): void |
A read-only API call that throws if something's wrong, such as a bad API key or a deleted list. |
canaryChecks(string $form): array |
For a listener that sends to several services: a read-only check for each, by name. Takes the place of canaryName() and canaryCheck(). See Several services in one listener. |
canaryEmails(string $form, Submission $submission): array |
The emails the listener builds for the form. See Emails built in code. |
canaryContact(string $form, string $email): ?string |
The ID of the contact with this email address, or null. Only needed for --subscribe. |
canaryDelete(string $form, string $id): void |
Deletes that contact. Only needed for --subscribe. |
A check must only read. It must never refresh or rewrite a stored token, add a contact or change a setting. While canaryCheck() runs, Canary blocks any request through Laravel's HTTP client that isn't a GET or HEAD, and fails the check even if the listener catches the error. A token refresh is a POST, so it would be blocked. SDKs with their own HTTP client, such as MailerLite's, aren't covered, so the rule is still yours to keep.
To tell Canary to leave a listener alone, return [] from its canaryForms().
MailerLite example
For a listener that uses the MailerLite PHP SDK and sends each form to a group:
use MailerLite\Exceptions\MailerLiteHttpException; use MailerLite\MailerLite; use Statamic\Events\FormSubmitted; class MailerLiteSubscriber { // Form handle => MailerLite group ID protected array $groups = [ 'newsletter' => '1234567890', 'statamic_alerts' => '0987654321', ]; public function handle(FormSubmitted $event) { /* adds the subscriber to the form's group */ } public function canaryName(): string { return 'MailerLite'; } public function canaryForms(): array { return array_map(fn () => ['email'], $this->groups); } public function canaryCheck(string $form): void { // Throws if the API key is wrong or the group has gone. $this->mailerLite()->groups->find($this->groups[$form]); } public function canaryContact(string $form, string $email): ?string { try { return $this->mailerLite()->subscribers->find($email)['body']['data']['id'] ?? null; } catch (MailerLiteHttpException $e) { if ($e->getCode() === 404) { return null; } throw $e; } } public function canaryDelete(string $form, string $id): void { $this->mailerLite()->subscribers->delete($id); } private function mailerLite(): MailerLite { return new MailerLite(['api_key' => config('services.mailerlite.api_key')]); } }
ActiveCampaign example
For a listener that calls the ActiveCampaign API with Laravel's HTTP client and adds each form to a list:
use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; use Statamic\Events\FormSubmitted; class ActiveCampaignSubscriber { // Form handle => ActiveCampaign list ID protected array $lists = [ 'contact' => 3, 'newsletter' => 7, ]; public function handle(FormSubmitted $event) { /* adds the contact to the form's list */ } public function canaryName(): string { return 'ActiveCampaign'; } public function canaryForms(): array { return array_map(fn () => ['email'], $this->lists); } public function canaryCheck(string $form): void { // Throws if the API key is wrong or the list has gone. $this->activeCampaign()->get("/api/3/lists/{$this->lists[$form]}")->throw(); } public function canaryContact(string $form, string $email): ?string { return $this->activeCampaign()->get('/api/3/contacts', ['email' => $email])->throw()->json('contacts.0.id'); } public function canaryDelete(string $form, string $id): void { $this->activeCampaign()->delete("/api/3/contacts/{$id}")->throw(); } private function activeCampaign(): PendingRequest { return Http::baseUrl(config('services.activecampaign.url')) ->withHeaders(['Api-Token' => config('services.activecampaign.key')]) ->acceptJson(); } }
services.activecampaign.url is your account's API URL, such as https://youraccount.api-us1.com, from Settings → Developer in ActiveCampaign. If a form adds contacts to an automation rather than a list, check the automation in canaryCheck() with GET /api/3/automations/{id} instead.
ActiveCampaign automations often notify the client's staff, for example by emailing their sales team when someone fills in the contact form. Deleting the test contact afterwards doesn't take that email back, so don't run --subscribe on a site like that.
One listener can handle many forms, and one form can have several listeners. Canary checks each listener for each form.
Several services in one listener
A listener that sends each submission to more than one service, such as Mailchimp for every form and a CRM for one, returns a check for each from canaryChecks():
public function canaryChecks(string $form): array { return array_filter([ 'Mailchimp' => fn () => $this->mailchimp->audience(config('services.mailchimp.audience')), 'Zoho' => $form === 'b2b_contact' ? fn () => $this->zoho->getCRMModuleID('Leads') : null, ]); }
Each service is listed by name in the picker and checked on its own, so one failing doesn't hide the other. A check that throws fails. One that returns a string passes with that as a warning, which suits a service that's been switched off on purpose:
INFO contact: Mailchimp check passed.
WARN contact: Zoho check passed, with a warning: disabled in Globals
The same read-only rule applies: a check must never refresh a stored token. Use the token as it is, so an expired one fails the check instead of being replaced.
Without the methods
Canary never adds these methods for you: it doesn't change your code. Without them, Canary reads the listener's code to find the forms it handles, so a site works out of the box. It recognises the usual ways a listener picks its forms:
if ($event->submission->form()->handle() == 'contact') if (in_array($event->submission->form()->handle(), ['contact', 'quote'])) if ($submission->form()->handle() !== config('acorn.form')) $groupId = $this->groups[$event->submission->form()->handle()] ?? null; $handle = $event->submission->form()->handle(); if ($handle === 'contact') if (Str::startsWith($handle, 'b2b_')) // matched against the site's forms match ($handle) { 'contact', 'quote' => ... } switch ($handle) { case 'contact': ... } if ($handle === 'old_contact') { return; } // leaves old_contact out
Forms that reach the listener through an else branch or a default arm can't be read reliably, so Canary doesn't guess. A form with nothing else to test shows nothing in the form's settings, App\Listeners\X may handle it instead of reporting nothing to test.
It also finds the fields a listener reads ($submission->email or $submission->get('email')) when it handles a single form, and every service the code names, such as ActiveCampaign or MailerLite. The list then shows:
contact - notification email, ActiveCampaign (no API check)
That's enough to check the listener is registered and the form still has its fields. The API check needs canaryCheck(), because only the listener knows the API key and the list. A listener without one isn't counted as a check that passed, so a form with no emails and only such listeners counts as tested nothing. If Canary can't work out a listener's forms at all, every run starts with a warning naming it:
WARN Canary can't tell which forms App\Listeners\ActiveCampaignSubscriber handles. See https://github.com/d3creativeuk/statamic-canary#checking-integrations.
Listeners from a package and closure listeners get their own warning, since you can't add methods to them. When Canary does find a package listener's forms, it names the listener after its addon or package, such as acorn - Acorn, and checks the registration and fields without pointing you here. A listener that only rejects submissions, such as a reCAPTCHA check that throws a ValidationException, isn't an integration, so Canary leaves it out.
For each integration on a form, a normal run checks that:
- The listener is registered for
FormSubmittedorSubmissionCreated. Canary also looks throughapp/Listenersfor classes with the Canary methods, so a listener someone has removed fromEventServiceProvideris reported, not missed. - The form still has the fields the listener reads. This catches a field renamed in the Control Panel.
- The API still answers.
canaryCheck()can confirm the API key works and the list or group still exists.
None of these write anything to the service, so it's safe to run as often as you like.
INFO 1 email(s) for statamic_alerts dispatched to you@example.com.
INFO statamic_alerts: MailerLite check passed.
ERROR newsletter: MailerLite check failed: [url] /api/groups/1234567890 [http method] GET [status code] 404 [reason phrase] Not Found.
Subscribing a test contact
--subscribe also tests the listener's own code, by sending it a test submission. For each integration with canaryContact() and canaryDelete(), Canary:
- Checks your test address isn't already a contact. If it is, Canary stops, because deleting it afterwards would remove a real subscription. Use a
--toaddress that isn't a contact. - Runs that listener alone with an unsaved submission. Other listeners don't run, and the submission can't be saved, even if the listener tries. Email fields get your test address, text fields get "Test submission", and message fields get "Test submission from D3 Creative. Please ignore.", so anyone looking at the service can see it's a test.
- Checks the contact arrived, then deletes it.
If the delete fails, Canary prints the contact's ID so you can delete it by hand.
Canary refuses --subscribe for a listener that sends to more than one service, because it runs the whole listener: the test contact would reach every service, and canaryDelete() can only remove it from one. It refuses when the listener has more than one check in canaryChecks(), and when its code names more than one service, even if only one is declared.
Deleting the contact doesn't undo what happened when it was added. If the list or group starts an automation, it may already have sent its first email to your test address, notified someone, or called a webhook. Check a site's automations before the first --subscribe run. A listener with its own rate limit may also ignore a second run within a short time; Canary reports that the contact never arrived.
Sending real submissions
Some forms have no emails of their own: a CRM automation, such as ActiveCampaign's, notifies the client's staff once the site has passed the enquiry on. A normal run can only check that the CRM is set up. --submit tests the whole path, the way a visitor's submission takes it:
php artisan canary:forms --submit --to=you@example.com
For each form, Canary:
- Makes a new test address from
--to, such asyou+test-contact-20261112140500@example.com, so it's never an existing contact and each test can be found later. Your mail provider needs to accept plus addresses. - Submits the form through Statamic's own form request and controller, with sample values. Validation, the honeypot,
FormSubmitted,SubmissionCreatedand every listener run as they would for a visitor, and their jobs go on the real queue. Only the HTTP layer is skipped: CSRF, the session and Statamic's route throttle. A validation error fails the form and names the field. - Sends the form's own emails to
--to, markedTEST:, as a normal run does. - Waits up to 90 seconds for the contact to reach each integration that has
canaryContact(). When the listener adds it from a job, that proves a queue worker ran the job. - Queues a delete of the test contact, through
canaryDelete(), for 15 minutes later (CANARY_DELETE_AFTER), once the integration's automations have had time to send.
The message fields say "Test submission from D3 Creative. Please ignore." (set the name with CANARY_FROM_NAME), and text fields say "Test submission". Nothing a recipient sees mentions Canary.
If every email arrives, the form, its validation, the queue worker, the integration and its automations all work.
Only the forms you list
A real submission reaches real systems, so --submit only runs on forms listed in canary.submit:
CANARY_SUBMIT=contact,quote
Only list a form once you know where its submissions go. If a CRM automation notifies staff, give it a branch for your test address first (for example, contacts whose email ends in your own domain notify only you), or agree the tests with the client. Canary can't see inside the CRM, so it can't tell.
Real recipients
--real-recipients sends the form's own emails to their real recipients instead of --to, with the subject marked TEST: . Use it to test a site's real routing, once the client knows to expect the emails.
Without it, Canary refuses a form whose listener builds and sends emails in code, because it can't redirect those.
What --submit can't undo
- Emails already sent. Notifications and auto-replies have gone by the time the contact is deleted.
- What's saved. A form that stores submissions saves the test one, labelled as a test.
- Integrations without
canaryContact()andcanaryDelete(). They run for real, and Canary warns that it can't check or remove what they added.
On the sync queue, nothing runs through a worker, and a delayed delete would run at once, so Canary leaves the test contact for you to delete by hand.
Canary doesn't change your site
Canary never edits your forms, templates, config or content.
- Form settings. Canary loads the form and swaps the recipients on that loaded copy, in memory, for the length of the run. The form's file is never written, so the next real submission goes to the real recipients as usual.
- Submissions. None are saved, even for forms that store submissions or accept file uploads, except by
--submit, which sends a real one. - Integrations.
FormSubmittedandSubmissionCreatedaren't fired, so newsletter, CRM and other form listeners don't run. Integration checks are read-only, and Canary blocks any request through Laravel's HTTP client during a check that isn't a GET or HEAD. Only--subscribeand--submitadd anything to a service, and they delete the test contact afterwards when the listener can.
The only file Canary writes is resources/addons/statamic-canary.yaml, and only when you choose to save your picked forms.
What could still email someone else
Canary controls the recipients it hands to Statamic. Anything that changes recipients after that is outside its control, for example:
- a custom
send_email_jobinconfig/statamic/forms.phpthat loads the form again or chooses its own recipients - a listener on Laravel's
MessageSendingevent, or a mail provider rule, that adds a recipient (such as an automatic BCC) to every email the site sends
On a site you don't know, start with --dry-run, which never hands an email to the mailer, and check for these before a real run. A trial run with MAIL_MAILER=log won't find them all: the log leaves out BCC addresses, a mail provider rule never runs, and a form email with its own mailer ignores that setting and sends as usual.
What Canary doesn't check
- The API, for listeners without
canaryCheck(). Canary can still find their forms and fields from their code, but only the listener knows how to reach the service. - Integrations that aren't listeners, such as a webhook from a third-party addon.
- Validation, except with
--submit. Otherwise sample values only need to render in a template, as Canary tests the email path, not the form. - The inbox. Canary proves the email jobs were dispatched. Checking that they arrived is up to you.
A scheduled mode that alerts you when a form stops sending, and a Control Panel page, aren't in this version yet.
Support
Canary is maintained by D3 Creative. Report bugs or ask questions in GitHub issues. Report security problems privately instead. See the security policy. For enquiries about managed Statamic maintenance, visit d3creative.uk/services/statamic-maintenance.
License
MIT. See LICENSE.



