stokoe/forms-to-wherever
A Laravel package for sending form submissions wherever they need to go. Configure destinations such as email, database, webhooks, or custom handlers through a simple, flexible API, keeping form handling clean and consistent.
Install
composer require michael-stokoe/statamic-forms-to-wherever
The repo name is statamic-forms-to-wherever while the Packagist/vendor string is stokoe/forms-to-wherever — verify the actual composer name in the repo's composer.json before running this, since it is not on the beaten path and may need a repositories entry (VCS or path) if it is unpublished.
Statamic addons are auto-discovered, so there is normally no manual providers entry. Confirm with:
php artisan about # addon listed under "Statamic"
php artisan vendor:publish --tag=forms-to-wherever-config
Where to look first
src/ServiceProvider.php — this is a base connector system, so the provider tells you what is bound into the container: a connector manager, a registry key, and the config namespace.config/forms-to-wherever.php — the connector list, default driver, and any credentials/queue settings.src/Connectors/ (or src/Contracts/) — the abstract connector and its contract. This is the thing you subclass; everything else is plumbing.README for the expected handler name to put in a form's YAML.First use case: push a contact form to an external destination
Create the form in the Control Panel (Forms → Contact), give it fields (name, email, message), then attach the connector as a handler in resources/forms/contact.yaml:
title: Contact
fields:
- handle: name
field:
type: text
display: Name
- handle: email
field:
type: text
input_type: email
- handle: message
field:
type: textarea
handlers:
- type: wherever # <- confirm the exact handle in the README
destination: crm
Then configure the crm destination in config/forms-to-wherever.php and submit the form once. Verify with php artisan tinker against the last submission, or by watching the destination's logs — do not trust a green "thank you" page, since the submission is stored before your handler runs.
Custom connector via the registry
The package's value is the extension point, not the shipped drivers. A typical pattern is a connector class plus a registry entry:
// app/Forms/Connectors/HubspotConnector.php
namespace App\Forms\Connectors;
use Stokoe\FormsToWherever\Contracts\Connector;
class HubspotConnector implements Connector
{
public function __construct(private array $config) {}
public function send($submission): void
{
Http::withToken($this->config['token'])
->post('https://api.hubapi.com/crm/v3/objects/contacts', [
'properties' => [
'email' => $submission->get('email'),
'firstname' => $submission->get('name'),
],
])
->throw();
}
}
Register it where the package's manager is booted (usually AppServiceProvider::boot(), or a small addon of your own):
Wherever::extend('hubspot', fn ($config) => new HubspotConnector($config));
Keep connectors stateless and resolve config in the closure — it makes them safe to serialize into queued jobs and easy to swap in tests.
Field mapping belongs in config, not in the connector
Submissions arrive with whatever handles the editor typed. Put the mapping in config/forms-to-wherever.php so a field rename in the blueprint does not require a deploy of PHP:
'destinations' => [
'crm' => [
'driver' => 'hubspot',
'token' => env('HUBSPOT_TOKEN'),
'fields' => [
'email' => 'email',
'firstname' => 'name',
'lastname' => 'surname',
],
],
],
Then one generic connector serves every form, and per-form differences stay in data rather than code.
Queue the send
Form submissions are synchronous request-time work. Wrap the outbound call:
public function handle($submission)
{
SendToDestination::dispatch($submission->id(), $this->destination);
}
Pass the submission id, not the submission object (see Gotchas), and re-resolve it in the job. Add ->onQueue('forms') and let Laravel's $tries/backoff handle flaky third-party APIs.
Environment parity / local development
Always ship a log or null driver and make it the default outside production:
'default' => env('WHEREVER_DRIVER', 'log'),
This lets you exercise the whole handler path locally without secrets, and stops a fresh clone from POSTing test data at a real CRM.
Testing
Drive the handler directly rather than through an HTTP form POST — faster and more precise:
$form = Form::find('contact');
$submission = $form->makeSubmission()->data(['email' => '[email protected]', 'name' => 'Ada']);
$form->submit($submission);
Http::assertSent(fn ($r) => $r['properties']['email'] === '[email protected]');
Use Http::fake() in the connector test and a fake connector implementation for the handler test, so the two layers fail independently.
Read the source first — there is no ecosystem to lean on. Zero stars, zero dependents, zero score means no blog posts, no Stack Overflow answers, no third party who has hit the bug before you. Budget an hour to read src/ end to end. It is likely small. Treat it as a reference implementation you are forking in spirit, not a stable dependency you install and forget.
Never queue the Submission model. Statamic's Submission is an Eloquent-ish model bound to a form and a run directory; serializing it into a job payload frequently blows up on SerializesModels or loses the form context on unserialize. Persist the ID and rehydrate:
$submission = Submission::find($this->submissionId);
Also guard against the submission having been deleted between dispatch and handle.
Failures are probably silent. A base connector layer typically calls send() and moves on; if the destination returns a 500, the visitor still sees the success page and the submission sits in the CP looking fine. Always ->throw() on the HTTP client and log the failure with the submission id. Consider writing an outbound_status field back onto the submission so the CP shows whether it actually left the building.
Config caching will bite you. php artisan config:cache in deploy pipelines freezes config/forms-to-wherever.php. If you compute destinations from a database table or a closure at runtime, caching breaks it. Keep config file contents scalar and env-derived only.
Honeypot and spam submissions still reach the handler. Statamic's honeypot rejects at the request layer, but submissions created via the API, imports, or a disabled honeypot arrive normally. Decide deliberately whether connectors should filter — usually you filter in the connector (e.g., skip if message contains links), because the CP still wants the record.
Attachments and asset fields are not portable. A file upload field holds a path relative to the submission's run directory, which is meaningless to an external API. If your destination needs bytes, load them during handling (before any cleanup job prunes runs) and stream them, or upload to your own storage first and send that URL.
Multi-site is unhandled by default. If the same form exists per site, submissions carry a site handle. Map it into your destination payload or you will silently merge locales in the CRM.
Version compatibility is on you. Statamic 4 → 5 changed Laravel and PHP floors; an addon this small may not have been updated. Pin the addon version in composer.json and check composer.json's require block for the Statamic constraint before upgrading the CMS.
Best extension point: your own connector, not a fork. Subclass, register via the registry, and keep the package untouched so a composer update is possible later. If you do need core changes, fork and use a path/VCS repository so the divergence is explicit rather than a patch applied by hand at deploy time.
How can I help you explore Laravel packages today?