Skip to content

Leads ​

Most people who look at a 2,400 euro course have a question before they book. This chapter puts an enquiry form on every course page and a newsletter sign-up in the footer. It lands every answer in the school's inbox, and passes leads on to the CRM and the mailing service the school already uses.

The forms ​

Forms are PHP classes. Texts are maps of language codes, read in the visitor's language. The enquiry asks for a name, an email, a question and consent:

php
/**
 * Questions about a course, from the course's page or the contact page. The page it was sent from
 * is stored with the submission, so staff (and the CRM) know which course it is about.
 */
final class EnquiryForm extends Form
{
    public function key(): string
    {
        return 'enquiry';
    }

    public function label(): array
    {
        return ['en' => 'Course enquiry', 'pt' => 'Pedido de informação'];
    }

    public function fields(): array
    {
        return [
            FormField::text('name')->label(['en' => 'Your name', 'pt' => 'O seu nome'])->required()->max(120)->width(50),
            FormField::email('email')->label('Email')->required()->replyTo()->width(50),
            FormField::tel('phone')->label(['en' => 'Phone (optional)', 'pt' => 'Telefone (opcional)'])->width(50),
            FormField::select('goal')->label(['en' => 'You want to', 'pt' => 'Quer'])->width(50)->options([
                'teach' => ['en' => 'Teach professionally', 'pt' => 'Ensinar profissionalmente'],
                'learn' => ['en' => 'Deepen my own practice', 'pt' => 'Aprofundar a minha prática'],
            ]),
            FormField::textarea('message')->label(['en' => 'Your question', 'pt' => 'A sua pergunta'])->required()->max(2000),
            FormField::checkbox('consent')->label(['en' => 'Atlas Training may contact me about this enquiry.', 'pt' => 'A Atlas Training pode contactar-me sobre este pedido.'])->required(),
        ];
    }

    public function successMessage(): array
    {
        return ['en' => 'Thank you! We reply within one working day.', 'pt' => 'Obrigado! Respondemos num dia útil.'];
    }

    /** The front desk, rather than everyone who may see submissions. */
    public function recipients(): array
    {
        return [(string) config('atlas.front_desk', 'hello@atlas-training.example')];
    }
}

recipients() names who gets the email: here the front desk, from your config. Return null to mail everyone whose role may see submissions, or [] to mail nobody, as the newsletter does:

php
/** The footer's newsletter sign-up: an address and consent, nothing more. */
final class NewsletterForm extends Form
{
    public function key(): string
    {
        return 'newsletter';
    }

    public function label(): array
    {
        return ['en' => 'Newsletter', 'pt' => 'Newsletter'];
    }

    public function fields(): array
    {
        return [
            FormField::email('email')->label('Email')->required(),
            FormField::checkbox('consent')->label(['en' => 'Send me news and new dates, about once a month.', 'pt' => 'Enviem-me novidades e novas datas, cerca de uma vez por mês.'])->required(),
        ];
    }

    public function successMessage(): array
    {
        return ['en' => 'You are on the list. See you soon!', 'pt' => 'Está na lista. Até breve!'];
    }

    /** Sign-ups go to the mailing service, not to anyone's inbox. */
    public function recipients(): array
    {
        return [];
    }
}

Register both in the service provider:

php
lynx()->forms()->register(EnquiryForm::class);
lynx()->forms()->register(NewsletterForm::class);

Placing them ​

The enquiry goes on the course page, under the dates, and the newsletter goes in the layout's footer:

blade
<livewire:lynx-form form="enquiry" :page-id="$page->id" />
<livewire:lynx-form form="newsletter" :page-id="$page->id" />

page-id is the page the visitor is on. It is stored with the submission, so the enquiry form needs no "which course?" field: staff see the course in the inbox, and your code reads it from $submission->page_id. The form checks each field as the visitor leaves it, shows the thank-you message without a reload, and speaks the visitor's language.

The forms module's stylesheet takes CSS variables, so the forms match Bloom with a few lines in atlas.css:

css
.lynx-form {
    --lynx-form-accent: var(--atlas-ink);
    --lynx-form-accent-text: #fff;
    --lynx-form-line: rgb(58 51 64 / 0.15);
}

Spam is handled for you. A hidden honeypot field and a timing check catch bots, which see the usual thanks while nothing is stored. Each visitor may send a form 5 times in 10 minutes.

The team's inbox ​

Forms in the admin's sidebar shows how many enquiries are new. The inbox lists them newest first, with a filter by status and form, a search across every answer, notes for the team, and a CSV export. Give the front desk a role with forms.view. Agents can read and sort submissions too: ask "summarise this week's enquiries" (see Forms).

Leads to the CRM ​

FormSubmitted fires once a submission is stored and committed. A queued listener sends each enquiry to the CRM, with the course it came from:

php
/**
 * Sends each course enquiry to the CRM as a lead, with the course it came from. Queued and retried,
 * so a slow or failing CRM never costs a lead.
 */
final class SendEnquiryToCrm implements ShouldQueue
{
    public int $tries = 5;

    /** @var list<int> */
    public array $backoff = [60, 300, 900, 3600];

    public function handle(FormSubmitted $event): void
    {
        // A copy without the service configured, such as a developer's machine: nothing to send.
        if (config('atlas.crm.url') === null) {
            return;
        }

        $submission = Submission::query()->find($event->submissionId);

        if ($submission === null || $submission->form !== 'enquiry') {
            return;
        }

        $course = $submission->page_id === null ? null : lynx()->pages->get($submission->page_id);

        Http::withToken((string) config('atlas.crm.token'))
            ->post(config('atlas.crm.url').'/leads', [
                'reference' => $submission->uuid,
                'name' => $submission->data['name'] ?? null,
                'email' => $submission->data['email'] ?? null,
                'phone' => $submission->data['phone'] ?? null,
                'goal' => $submission->data['goal'] ?? null,
                'course' => $course?->template()->name === 'course' ? (string) $course->getLanguageValue('en', 'title') : null,
                'language' => $submission->language()?->code,
            ])
            ->throw();
    }
}

The listener is queued, so a slow CRM never slows the visitor. It throws when the CRM answers with an error, so the queue tries again with growing pauses, and the submission's uuid lets the CRM ignore a lead it already has. Laravel finds listeners in app/Listeners by the event their handle() takes, so it needs no registration. The CRM's address and token come from config/atlas.php, which reads them from .env (ATLAS_CRM_URL, ATLAS_CRM_TOKEN).

Sign-ups to the mailing list ​

The newsletter's listener tags each address with its language, so Portuguese readers get the Portuguese edition:

php
/**
 * Adds newsletter sign-ups to the mailing list, tagged with their language so Portuguese readers
 * get the Portuguese edition. The mailing service sends its own confirmation email (double opt-in).
 */
final class SubscribeToNewsletter implements ShouldQueue
{
    public int $tries = 5;

    public function handle(FormSubmitted $event): void
    {
        // A copy without the service configured, such as a developer's machine: nothing to send.
        if (config('atlas.newsletter.url') === null) {
            return;
        }

        $submission = Submission::query()->find($event->submissionId);

        if ($submission === null || $submission->form !== 'newsletter') {
            return;
        }

        Http::withToken((string) config('atlas.newsletter.token'))
            ->post(config('atlas.newsletter.url').'/subscribers', [
                'email' => $submission->data['email'] ?? null,
                'tags' => ['website', 'lang-'.($submission->language()?->code ?? 'en')],
                'double_opt_in' => true,
            ])
            ->throw();
    }
}

Run a queue worker in production (php artisan queue:work, kept alive by Supervisor or your host), or both listeners wait forever.

What you get ​

  • The client: an inbox of enquiries that says which course each is about, leads in their CRM within seconds, and a growing mailing list split by language.
  • You: forms as classes in Git, validation and spam protection built in, and one event to hang every integration on.

Released under the MIT License.