Signup Forms
Courier's helper layer makes wiring up signup forms a one-liner. Drop a form anywhere in your views, handle the submission with a single function call, or mix and match for full control over your markup.
Loading the helper
Load it in any controller or view:
To load it automatically on every request, add it to your Config/Autoload.php:
Drop-in form
courier_form() returns a complete, self-contained HTML form that posts to Courier's built-in capture endpoint:
That's all you need for a working email capture form. Add options to control behaviour:
<?php
echo courier_form('homepage-signup', [
'tags' => ['newsletter'], // apply these tags on subscribe
'drip' => 3, // enroll in drip campaign #3
'redirect' => '/welcome', // where to send the user on success
'fields' => ['first_name'], // extra fields: 'first_name', 'last_name'
'button' => 'Join the list', // submit button label
'class' => 'signup-form', // CSS class on the <form> element
]);
The $source argument is a slug identifying where this form lives — 'homepage-signup', 'blog-sidebar', etc. It's stored on the contact record so you can trace where subscribers came from.
The generated HTML looks like this:
<form action="/courier/capture" method="POST" class="signup-form">
<input type="hidden" name="csrf_test_name" value="...">
<input type="hidden" name="courier_source" value="homepage-signup">
<input type="hidden" name="courier_tags" value='["newsletter"]'>
<input type="hidden" name="courier_redirect" value="/welcome">
<input type="email" name="email" required placeholder="Your email">
<input type="text" name="first_name" placeholder="First name">
<button type="submit">Join the list</button>
</form>
CSRF protection
The form includes a CSRF token automatically via CI4's csrf_field(). Make sure the CI4 CSRF filter is active in your app — it's on by default for POST routes.
Custom form layout
If you need to embed the capture fields inside your own existing form, use courier_form_open() and courier_form_close() instead:
<?php
echo courier_form_open('footer-cta', [
'tags' => ['newsletter'],
'redirect' => '/subscribed',
]);
?>
<div class="form-row">
<input type="email" name="email" required placeholder="Your email address">
<button type="submit">Subscribe</button>
</div>
<?php echo courier_form_close(); ?>
courier_form_open() outputs the <form> tag, CSRF field, and all the hidden Courier fields. courier_form_close() outputs </form>. Everything in between is yours.
Handling the submission yourself
If you'd rather own the controller method — to add custom logic, extra validation, or a different redirect — use courier_capture():
<?php
use CodeIgniter\HTTP\ResponseInterface;
class MarketingController extends BaseController
{
public function newsletter(): ResponseInterface
{
helper('courier');
return courier_capture($this->request, [
'tags' => ['newsletter'],
'drip' => 5,
'redirect' => '/thank-you',
]);
}
}
courier_capture() reads the POST body, validates the email, subscribes the contact, and returns a ready-made redirect or JSON response. The $defaults array lets you lock in values server-side so POST data can't override them:
| Key | Type | Description |
|---|---|---|
tags |
array |
Merged with any tags from POST |
drip |
int |
Overrides the POST courier_drip_id value |
redirect |
string |
Overrides the POST courier_redirect value |
source |
string |
Overrides the POST courier_source value |
ajax |
bool |
Forces JSON responses regardless of the request type |
Your controller's route doesn't need to be POST /courier/capture — point courier_form() at your own endpoint by constructing the form manually with courier_form_open(), or just use the built-in capture endpoint and skip the custom controller entirely.
Error handling
On validation failure, courier_capture() returns automatically:
- Non-AJAX:
redirect()->back()->withInput()->with('courier_errors', $errors) - AJAX:
{"success": false, "errors": {"email": "A valid email is required."}}with HTTP 422
In your view, render errors like this:
<?php if ($courierErrors = session('courier_errors')): ?>
<p class="error"><?= esc($courierErrors['email']) ?></p>
<?php endif ?>
AJAX / fetch() forms
Set ajax => true to get JSON responses instead of redirects. The courier_redirect hidden field is also omitted from the form:
Pair it with a small fetch handler:
document.querySelector('[data-courier-ajax]').addEventListener('submit', async (e) => {
e.preventDefault();
const res = await fetch(e.target.action, {
method: 'POST',
body: new FormData(e.target),
});
const data = await res.json();
if (data.success) {
// show success message
} else {
// show data.errors.email
}
});
Courier detects AJAX requests via the X-Requested-With: XMLHttpRequest header. If you're using axios, set the header once globally and the ajax flag becomes optional:
Success response:
Validation error response (HTTP 422):
Unsubscribe links
Email layouts receive $unsubscribeUrl automatically. If you need the URL somewhere else — an account settings page, a transactional email, a custom template — use courier_unsubscribe_url():
<?php
$url = courier_unsubscribe_url($contact);
// https://yoursite.com/courier/unsubscribe/abc123token
The base URL comes from $trackingHost in your Courier config, falling back to CI4's base_url().
Security
Courier validates and sanitises the data it receives before acting on it. A few things worth knowing:
Redirect safety. The courier_redirect field in POST data is restricted to relative paths. If an absolute URL (https://...) is submitted — whether by a tampered form or a crafted POST request — Courier replaces it with /. Values passed via $defaults['redirect'] in your own controller are subject to the same check.
Tag slugs. Tags submitted via courier_tags are validated before reaching the database. Only lowercase alphanumeric slugs (with - and _ separators, up to 64 characters) are accepted. Anything else is silently dropped. Valid format: newsletter, beta-users, plan_pro.
Re-subscribe behaviour. If a contact previously unsubscribed, submitting their email re-subscribes them silently — no error is shown to the user. Contacts with bounced or complained status cannot be re-subscribed; courier_capture() returns a validation error in that case.
Next steps
- Contacts — how
ContactServicemanages the full contact lifecycle - Drip Sequences — setting up the drip campaigns you pass to
drip - Tracking — how the built-in capture endpoint fits into Courier's route group