Skip to content

Email Templates

Courier supports two ways to write email content: PHP view files (full CI4 view system, great for complex HTML) and markdown files (simpler syntax, ideal for plain prose emails). Both work with layouts and tracking placeholders.

How layouts work

A layout is an outer HTML shell — the <html>, <head>, and structural table markup. The campaign body provides just the inner content. Courier renders the body first, then injects it into the layout via a $content variable.

layout.php          ← outer HTML, header, footer
  └─ body view.php  ← your campaign's content

The default layout is at Views/courier/layouts/default.php. It's a simple 600px responsive email with a dark header, white body, and light footer.

Creating a PHP view

A body view is a plain PHP view file that outputs HTML email content. Keep it simple — inline styles, table-based layout if you need columns.

<!-- app/Views/emails/may_newsletter.php -->

<p>Hi <?= esc($contact->first_name ?? 'there') ?>,</p>

<p>Here's what's new this month at Acme.</p>

<p>
    <a href="https://acme.com/blog/may-update"
       style="display:inline-block;padding:12px 24px;background:#2563eb;color:#fff;
              text-decoration:none;border-radius:4px;font-weight:bold;">
        Read the update
    </a>
</p>

<p>Talk soon,<br>The Acme Team</p>

Available variables

Variable Type Description
$contact ContactDTO The recipient — has email, first_name, last_name, custom_fields, etc.
$subject string The campaign or drip step subject line

Any additional data you pass to CampaignService::create() or addDripStep() is not automatically available in views — pass extra data via a custom service or use $contact->custom_fields.

Creating a markdown file

If your email is mostly prose, markdown is often easier to write and maintain than a PHP view. Set the view field to a path ending in .md and Courier handles the rest.

$campaignService->create([
    'name'       => 'May Newsletter',
    'subject'    => 'What\'s new at Acme',
    'from_name'  => 'Acme',
    'from_email' => 'hello@acme.com',
    'view'       => 'emails/may_newsletter.md',  // ← .md extension = markdown mode
]);

By default, Courier resolves markdown paths relative to APPPATH, so emails/may_newsletter.md maps to app/emails/may_newsletter.md. You can change this with the $markdownPath config option — see Configuration.

Writing markdown email content

Use standard GitHub Flavored Markdown: headings, bold, italic, links, bullet lists, and tables all work.

Hi {first_name}!

Here's what's new this month at Acme.

**New features this month:**

- Faster dashboard loading
- Dark mode support
- Improved CSV export

[Read the full update](https://acme.com/blog/may-update)

Talk soon,
The Acme Team

[Unsubscribe]({courier_unsubscribe_url})
{courier_tracking_pixel}

Token substitution

PHP views use $contact->first_name. Markdown files use {token} placeholders instead. Courier replaces them before rendering.

Available tokens:

Token Source
{first_name} $contact->first_name
{last_name} $contact->last_name
{email} $contact->email
{unsubscribe_token} $contact->unsubscribe_token
{source} $contact->source
{subject} The campaign or drip step subject line
Any other scalar ContactDTO field Automatically available

Tokens with no matching value are left as-is in the rendered output.

Unsubscribe links in markdown

You can use {courier_unsubscribe_url} directly in a markdown link:

[Unsubscribe]({courier_unsubscribe_url})
Courier automatically restores the placeholder after markdown rendering so it gets replaced correctly before sending.

Tracking placeholders

These work the same in both PHP views and markdown files:

Placeholder Replaced with
{courier_unsubscribe_url} A unique one-click unsubscribe URL for this contact
{courier_tracking_pixel} A 1×1 invisible image that records opens

Include the unsubscribe link

CAN-SPAM and GDPR both require a way to opt out. Make sure {courier_unsubscribe_url} appears in every email. The default layout already includes it in the footer — if you write a custom layout or a self-contained markdown file, add it yourself.

Place them in your layout so every campaign gets them automatically:

<!-- in your layout footer -->
<a href="{courier_unsubscribe_url}">Unsubscribe</a>

<!-- at the very end of <body> -->
{courier_tracking_pixel}

Using a custom layout

Point a campaign at your own layout view — this works for both PHP views and markdown files:

$campaignService->create([
    // ...
    'view'   => 'emails/may_newsletter.md',          // markdown body
    'layout' => 'App\Views\emails\layouts\branded',  // PHP layout wraps it
]);

Your layout needs to output <?= $content ?> where the body should appear.

To change the default for all campaigns, update $defaultLayout in your config:

public string $defaultLayout = 'App\Views\emails\layouts\branded';

Plain-text fallback

Courier generates a plain-text alternative automatically. The behavior differs slightly by template type:

  • PHP views — Courier renders the body view, strips HTML tags, and collapses whitespace.
  • Markdown files — The raw markdown source is used directly. It's already readable as plain text, so no stripping is needed.

Either way, the unsubscribe URL is appended at the bottom. You don't need to maintain a separate plain-text file.

Testing your templates

Set testMode = true in your config and trigger a send — Courier logs the recipient and subject instead of sending. To preview the rendered HTML directly, use TemplateService:

$html = service('templateService')->render(
    'App\Views\emails\may_newsletter',
    'App\Views\emails\layouts\branded',
    ['contact' => $contact, 'subject' => 'Preview']
);
$html = service('templateService')->render(
    'emails/may_newsletter.md',
    'App\Views\emails\layouts\branded',
    ['contact' => $contact, 'subject' => 'Preview']
);

Next steps

  • Configuration — set $markdownPath to control where markdown files are resolved from
  • Campaigns — create a blast or drip campaign that uses your template
  • Tracking — how open and click tracking work under the hood