diff --git a/quickstart/laravel/templates.mdx b/quickstart/laravel/templates.mdx
index 706e77e..c3412f7 100644
--- a/quickstart/laravel/templates.mdx
+++ b/quickstart/laravel/templates.mdx
@@ -73,14 +73,18 @@ php artisan lettr:pull
```
Options:
-- `--template=slug` - Pull a specific template
+- `--template=slug` - Pull a specific template. Exits with a failure code if it doesn't exist
- `--as-html` - Save as HTML instead of Blade
-- `--with-mailables` - Also generate Mailable classes
+- `--with-mailables` - Also generate Mailable and DTO classes
+- `--skip-templates` - With `--with-mailables`, generate only DTOs and Mailables, without downloading templates
+- `--dry-run` - Preview what would be downloaded without writing files
By default, templates are saved as Blade files to `resources/views/emails/lettr/`. HTML files go to `resources/templates/lettr/`. Both paths are configurable in `config/lettr.php`.
+When converting to Blade, merge tags become variables (`{{first_name}}` → `{{ $first_name }}`) and loops become null-safe `@foreach` blocks whose items are read as arrays (`{{#each items}}{{this.name}}` → `@foreach($items ?? [] as $item){{ $item['name'] }}`), which is the shape generated DTOs pass to the view.
+
- Use `--with-mailables` to generate ready-to-use Mailable classes alongside the templates. This is the fastest way to scaffold email classes from your existing Lettr templates.
+ Use `--with-mailables` to generate ready-to-use Mailable classes alongside the templates. This is the fastest way to scaffold email classes from your existing Lettr templates. See [Mailable Classes](/quickstart/laravel/type-safety#mailable-classes) for the two kinds of Mailable it can generate.
## Push Templates to Lettr
@@ -91,8 +95,10 @@ Upload local Blade templates to Lettr, creating them if they do not exist:
php artisan lettr:push
```
+Without `--path`, the command looks for templates in `blade_path` from `config/lettr.php` (where `lettr:pull` saves them), then in `resources/views/emails`, `mails`, `email` and `mail`, and asks before using a folder.
+
Options:
-- `--path=` - Custom path to the templates directory
+- `--path=` - Custom path to the templates directory, absolute or relative to your project root
- `--template=filename` - Push only one template
- `--purpose=` - Module to create the templates in: `transactional` (default) or `campaign`
- `--dry-run` - Preview what would be created without pushing
@@ -121,17 +127,22 @@ foreach ($response->templates as $template) {
}
```
-Get a specific template:
+Get a specific template and its merge tags:
```php
$template = Lettr::templates()->get('welcome-email');
echo $template->name;
echo $template->activeVersion;
-print_r($template->mergeTags);
+
+$response = Lettr::templates()->getMergeTags('welcome-email', version: $template->activeVersion);
+
+foreach ($response->mergeTags as $mergeTag) {
+ echo $mergeTag->key . ($mergeTag->required ? ' (required)' : '');
+}
```
-The `mergeTags` property returns an array of merge tag names defined in the template, which is useful for validating your substitution data before sending.
+Each merge tag has a `key`, a `required` flag, a `type`, and `children` for loop blocks, which is useful for validating your substitution data before sending.
## What's Next
diff --git a/quickstart/laravel/type-safety.mdx b/quickstart/laravel/type-safety.mdx
index 5d9e0cd..2025781 100644
--- a/quickstart/laravel/type-safety.mdx
+++ b/quickstart/laravel/type-safety.mdx
@@ -3,17 +3,21 @@ title: Type Safety
description: "Generate type-safe PHP enums, DTOs, and Mailables from your Lettr templates in Laravel to catch slug and merge tag errors early"
---
-Generate type-safe PHP code from your Lettr templates. Catch errors at compile time instead of runtime.
+Generate type-safe PHP code from your Lettr templates, so mistakes show up in your editor instead of in a sent email.
-The SDK provides three Artisan commands that connect to the Lettr API, read your templates and their merge tags, and generate PHP classes you can use in your application. This means your IDE can autocomplete template slugs, enforce required merge tags, and flag typos before you deploy.
+The SDK provides three Artisan commands that connect to the Lettr API, read your templates and their merge tags, and generate PHP classes you can use in your application. Your IDE can then autocomplete template slugs, enforce required merge tags, and flag typos before you deploy.
+
+
+ This page describes `lettr/lettr-laravel` **2.7.0 and later**. On 2.6.x the generated code looks different: DTO properties lose camelCase, Blade loops use `$item->key`, Mailables for Lettr templates set a subject, and none of the files carry a generated-file docblock. Upgrade with `composer update lettr/lettr-laravel`, then re-run the commands.
+
## Why Code Generation?
Using string literals for template slugs and merge tags is error-prone:
```php
-// ❌ Typos cause runtime errors
-Mail::lettr()->sendTemplate('welcom-email', 'Welcome!', ['nme' => 'John']);
+// ❌ Typos only surface when the email goes out
+Mail::lettr()->sendTemplate('welcom-email', 'Welcome!', ['frist_name' => 'John']);
```
With generated code, your IDE catches mistakes immediately:
@@ -23,11 +27,11 @@ With generated code, your IDE catches mistakes immediately:
Mail::lettr()->sendTemplate(
LettrTemplate::WelcomeEmail->value,
'Welcome!',
- new WelcomeEmailData(name: 'John')
+ new WelcomeEmailData(firstName: 'John', activationUrl: $url),
);
```
-The first example silently sends an email with the wrong template slug and missing merge tags. The second example fails at compile time with clear error messages — `LettrTemplate::WelcomEmail` doesn't exist, and `WelcomeEmailData` requires `name`, not `nme`.
+The first example sends with a slug that doesn't exist and a merge tag the template never reads. In the second, your IDE and static analysis flag both mistakes: `LettrTemplate::WelcomEmail` doesn't exist, and `WelcomeEmailData` requires `firstName` and `activationUrl`.
## Generate Everything at Once
@@ -52,19 +56,34 @@ php artisan lettr:generate-enum
This creates `app/Enums/LettrTemplate.php`:
```php
+
+ */
+ public function toArray(): array
+ {
+ return [
+ 'first_name' => $this->firstName,
+ 'activation_url' => $this->activationUrl,
+ 'company_name' => $this->companyName,
+ ];
+ }
+}
+```
+
+The command reads each template's merge tags from its active version and generates a readonly class with typed constructor parameters:
+
+- **Required merge tags** become required parameters and come first; optional ones default to `null`.
+- **Property names** are camelCase: `first_name` and `FIRST_NAME` become `$firstName`, and `orderId` stays `$orderId`. If two keys would end up with the same name (`FIRST_NAME` and `first_name` in one template), both keep their original key as the property name instead.
+- **`toArray()`** maps the properties back to the original merge tag keys. Optional values you leave out are sent as `null`.
+- **Class names** follow the same rules as enum cases, plus a `Data` suffix. PHP reserves more words for class names than for enum cases, so a template with the slug `new` gets `NewTemplateData`.
+
+Templates without merge tags, or without an active version, are skipped and listed in the command output. `--template` with a slug that doesn't exist exits with a failure code.
+
+### Loops
+
+A [loop block](/learn/templates/loop-blocks) gets its own item DTO, and the parent takes an array of them:
+
+```php
+final readonly class OrderConfirmationData implements Arrayable
{
+ /**
+ * @param OrderConfirmationDataItemData[] $items
+ */
public function __construct(
- public string $first_name,
- public string $activation_url,
- public ?string $company_name = null,
+ public array $items,
) {}
+ /**
+ * @return array
+ */
public function toArray(): array
{
- return array_filter([
- 'first_name' => $this->first_name,
- 'activation_url' => $this->activation_url,
- 'company_name' => $this->company_name,
- ], fn($v) => $v !== null);
+ return [
+ 'items' => array_map(fn (OrderConfirmationDataItemData $item) => $item->toArray(), $this->items),
+ ];
}
}
```
-The command reads each template's merge tags from the Lettr API and generates a DTO class with typed constructor parameters. Required merge tags become required parameters; optional ones get default `null` values. The `toArray()` method strips null values so only provided merge tags are sent.
+```php
+new OrderConfirmationData(items: [
+ new OrderConfirmationDataItemData(name: 'Notebook', quantity: 2),
+]);
+```
+
+If the loop merge tag is optional, you can leave it out and `toArray()` sends `null` for it.
### Usage
+`sendTemplate()` accepts the DTO directly, because it implements `Arrayable`:
+
```php
use App\Dto\Lettr\WelcomeEmailData;
$data = new WelcomeEmailData(
- first_name: 'John',
- activation_url: $url,
+ firstName: 'John',
+ activationUrl: $url,
);
Mail::lettr()
->to($user->email)
- ->sendTemplate('welcome-email', 'Welcome!', $data->toArray());
+ ->sendTemplate('welcome-email', 'Welcome!', $data);
```
### Benefits
@@ -145,7 +225,7 @@ Mail::lettr()
```php
Mail::lettr()
->to($user->email)
- ->sendTemplate(LettrTemplate::WelcomeEmail->value, 'Welcome!', $data->toArray());
+ ->sendTemplate(LettrTemplate::WelcomeEmail->value, 'Welcome!', $data);
```
@@ -153,61 +233,157 @@ Mail::lettr()
## Mailable Classes
-Generate Mailable classes for your templates:
+Generate a Mailable class, and a DTO for its merge tags, for each template:
```bash
php artisan lettr:pull --with-mailables
```
-This creates Mailables in `app/Mail/Lettr/`:
+Mailables are created in `app/Mail/Lettr/`. What they send depends on how you pull:
+
+| Command | The Mailable sends | Subject |
+|---------|--------------------|---------|
+| `lettr:pull --with-mailables` | The Blade view pulled to `resources/views/emails/lettr/`, rendered by your app | Generated from the template name. Edit it in `envelope()` |
+| `lettr:pull --with-mailables --as-html`
`lettr:pull --with-mailables --skip-templates` | The template stored in Lettr, by slug. Your app sends only the merge tag data | The subject set on the template in Lettr, unless you set one in `envelope()` |
+
+### Lettr template Mailables
+
+With `--as-html` or `--skip-templates`, the Mailable points at the template in Lettr. Editing the template in the dashboard changes the email without a deploy:
```php
+
+ */
+ public function withMergeTags(): array
+ {
+ return $this->data->toArray();
+ }
+}
+```
+### Blade Mailables
+
+Without those flags, the template is saved as a Blade view and the Mailable renders it locally. `LettrMailable` passes whatever `withMergeTags()` returns — here the DTO's `toArray()` — to the view. Loop items reach the view as arrays, so the pulled view reads them as `$item['name']`, and a loop you leave out renders nothing:
+
+```php
+/**
+ * Sends the Blade view `emails.lettr.welcome-email`, pulled from the Lettr
+ * template `welcome-email` and rendered by your app.
+ *
+ * Generated by `php artisan lettr:pull --with-mailables`. Running it again
+ * overwrites this file, so commit any changes you make here before pulling
+ * again.
+ */
class WelcomeEmail extends LettrMailable
{
+ /**
+ * The Blade view for this email.
+ */
+ protected ?string $bladeView = 'emails.lettr.welcome-email';
+
+ /**
+ * Create a new message instance.
+ */
public function __construct(
- private string $firstName,
- private string $activationUrl,
+ public readonly WelcomeEmailData $data,
) {}
+ /**
+ * Get the message envelope.
+ */
public function envelope(): Envelope
{
return new Envelope(
- subject: 'Welcome to Our App',
+ subject: 'Welcome Email',
);
}
- public function build(): static
+ /**
+ * Get the merge tags for this mailable.
+ *
+ * @return array
+ */
+ public function withMergeTags(): array
{
- return $this
- ->template('welcome-email')
- ->substitutionData([
- 'first_name' => $this->firstName,
- 'activation_url' => $this->activationUrl,
- ]);
+ return $this->data->toArray();
}
}
```
-Generated Mailables extend `LettrMailable` and follow standard Laravel conventions. The constructor takes typed parameters for each merge tag, and the `build()` method maps them to the template's substitution data.
+The view name comes from `blade_path` in `config/lettr.php`, so a custom path still resolves as long as it's inside one of your view directories.
### Usage
+Either kind is sent like any other Mailable:
+
```php
+use App\Dto\Lettr\WelcomeEmailData;
use App\Mail\Lettr\WelcomeEmail;
Mail::to($user->email)->send(
- new WelcomeEmail(
+ new WelcomeEmail(new WelcomeEmailData(
firstName: $user->first_name,
activationUrl: $url,
- )
+ ))
);
```
+### Re-running the Command
+
+Every run rewrites all the files it generates: Blade views, HTML files, DTOs, Mailables and the enum. Files that already existed are marked with ↻ in the output, followed by a warning:
+
+```text
+WARN 3 existing file(s) were overwritten (marked ↻). Any local changes to them are gone; check your version control if that wasn't intended.
+```
+
+If you've edited a generated Mailable (a from address, attachments, a custom subject), commit it first so you can bring the changes back, or run with `--dry-run` to see which files would be overwritten.
+
### Benefits
- **Standard Laravel** - Works with queues, events, and testing
@@ -228,8 +404,12 @@ php artisan lettr:generate-enum
php artisan lettr:generate-dtos
```
+Every generated class has a docblock naming the command that generated it and saying the next run overwrites it. A Mailable for a template without merge tags has no constructor. The files follow Laravel's code style, so Pint leaves them unchanged.
+
Generated files are safe to commit to version control. They act as a snapshot of your templates at generation time. If a template is renamed or a merge tag is added in Lettr, the generated code won't update automatically — run the commands again to pick up changes.
+Regenerating replaces the enum and DTOs. A DTO for a template that no longer has merge tags isn't deleted, so remove it yourself.
+
Add these commands to your CI/CD pipeline to ensure code stays in sync with your templates. If the generated output differs from what's committed, you know someone updated a template without regenerating.
@@ -247,6 +427,9 @@ Customize paths and names in `config/lettr.php`:
```php
'templates' => [
+ 'html_path' => resource_path('templates/lettr'),
+ 'blade_path' => resource_path('views/emails/lettr'),
+
'enum_path' => app_path('Enums'),
'enum_namespace' => 'App\\Enums',
'enum_class' => 'LettrTemplate',
@@ -261,6 +444,8 @@ Customize paths and names in `config/lettr.php`:
| Option | Default | Description |
|--------|---------|-------------|
+| `html_path` | `resources/templates/lettr` | Directory for HTML files from `lettr:pull --as-html` |
+| `blade_path` | `resources/views/emails/lettr` | Directory for Blade views from `lettr:pull`. Keep it inside a view directory so Blade Mailables can find their views |
| `enum_path` | `app/Enums` | Directory for the generated enum file |
| `enum_namespace` | `App\Enums` | PHP namespace for the enum |
| `enum_class` | `LettrTemplate` | Class name for the generated enum |
@@ -269,14 +454,30 @@ Customize paths and names in `config/lettr.php`:
| `mailable_path` | `app/Mail/Lettr` | Directory for generated Mailable files |
| `mailable_namespace` | `App\Mail\Lettr` | PHP namespace for Mailables |
+Paths can be absolute or relative to your project root. Any key you leave out of a published `config/lettr.php` falls back to its default.
+
+Each namespace has to match its path under the `autoload.psr-4` section of your `composer.json`, or the generated classes won't load. The commands warn when they don't match:
+
+```text
+WARN App\Data\Lettr\WelcomeEmailData won't autoload from app/Dto/Lettr/WelcomeEmailData.php. Check that the namespace and path in config/lettr.php match the PSR-4 autoload section of composer.json.
+```
+
## Artisan Commands Reference
| Command | What It Generates | Output Location |
|---------|-------------------|-----------------|
-| `lettr:init` | Enum + DTOs + config (interactive) | Configured paths |
+| `lettr:init` | Enum, DTOs and Mailables (interactive) | Configured paths |
| `lettr:generate-enum` | Template slug enum | `app/Enums/LettrTemplate.php` |
| `lettr:generate-dtos` | Merge tag DTOs | `app/Dto/Lettr/` |
-| `lettr:pull --with-mailables` | Mailable classes + Blade templates | `app/Mail/Lettr/` + `resources/views/emails/lettr/` |
+| `lettr:pull --with-mailables` | Blade views, DTOs and Mailables | `resources/views/emails/lettr/`, `app/Dto/Lettr/`, `app/Mail/Lettr/` |
+
+| Option | Commands | Description |
+|--------|----------|-------------|
+| `--dry-run` | all three | Preview what would be generated without writing files |
+| `--template=` | `lettr:pull`, `lettr:generate-dtos` | Only one template, by slug. Exits with a failure code if it doesn't exist |
+| `--with-mailables` | `lettr:pull` | Also generate Mailables and DTOs |
+| `--as-html` | `lettr:pull` | Save raw HTML, and generate Mailables that send the Lettr template |
+| `--skip-templates` | `lettr:pull` | Don't download templates; with `--with-mailables`, generate DTOs and Mailables that send the Lettr template |
## What's Next