Skip to content

fix: run Lettr setup even when build() is overridden - #15

Open
voj-tech-j wants to merge 1 commit into
mainfrom
fix/lettr-mailable-build-override
Open

fix: run Lettr setup even when build() is overridden#15
voj-tech-j wants to merge 1 commit into
mainfrom
fix/lettr-mailable-build-override

Conversation

@voj-tech-j

Copy link
Copy Markdown
Contributor

Problem

Overriding build() in a LettrMailable subclass without calling parent::build() — the pattern shown in README.md:195, :246 and :368 — makes delivery fail outright:

InvalidArgumentException: Invalid view.

Laravel's Mailable::prepareMailableForDelivery() calls build() via Container::call(), so a subclass override wins and none of the Lettr setup runs: no mailer('lettr'), no X-Lettr-* headers, no placeholder body. content() then returns an empty Content in API-template mode, $this->view stays null, and Mailer::parseView(null) throws.

The generated stubs are unaffected (they declare $templateSlug as a property and never override build()), so this only hit hand-written mailables that followed the README.

Fix

The setup moved out of build() into prepareLettrDelivery(), driven by two hooks Laravel always reaches:

  • prepareMailableForDelivery() (overridden) — runs on every send and render, after build()/content(), so no subclass override can skip it.
  • content() — a second entry point in case that protected framework method ever changes.

prepareLettrDelivery() is idempotent: the withSymfonyMessage callback is registered once, and everything it reads (slug, version, substitution data, tags) resolves when the callback fires at send time — so the call order relative to template()/substitutionData() no longer matters. build() remains as a thin wrapper for backwards compatibility.

Compatibility

Patch-level. Every pattern that worked before still works; previously-failing code now works.

Pattern Before After
build() override without parent::build() (README) Invalid view works
build() override with parent::build() works unchanged
$templateSlug property, no build() (stubs) works unchanged
Blade-view mailables works unchanged — setup returns early

Three deliberate deltas:

  1. A body set by the mailable is no longer overwritten by the This email uses Lettr template: … placeholder. Wire output is identical — LettrTransportFactory renders from the slug header and ignores the body in template mode. This is load-bearing: the setup now runs after content hydration, so without it a subclass returning Content(htmlString: …) would have its body clobbered.
  2. content() now has a side effect — calling it directly in a test mutates the mailable.
  3. A subclass deliberately using LettrMailable to not send via Lettr would now get X-Lettr-* headers, and on the queued path would route to the lettr transport. Such code raised Invalid view before unless it also set a view, so the population is near-zero.

prepareMailableForDelivery() is a protected framework internal, stable since Laravel 9 and present across the ^10|^11|^12|^13 range in composer.json. If it ever disappears, the content() hook still covers every subclass that doesn't override content().

Tests

New tests/Unit/LettrMailableTest.php — 8 tests that actually send through the array transport and assert the resulting Symfony headers, covering all four subclass patterns plus headers-registered-exactly-once, placeholder body, explicit-body-wins, and Blade untouched. 5 of the 8 fail against the pre-fix implementation.

They're in a new file rather than ReadmeDocTest.php because that file is generated by the readme-doc-test skill and would drop them on regeneration. Worth noting why the gap existed: ReadmeDocTest calls $mailable->build() and reflects on properties without ever sending, so it passed on code that could not be delivered.

pest 225 passed · phpstan no errors · pint pass.

The README examples now work as written, so they're unchanged. CHANGELOG.md gets an [Unreleased] entry — LettrServiceProvider::VERSION is untouched since bumping and tagging is a separate release step; this is a 2.3.1.

🤖 Generated with Claude Code

Overriding build() in a LettrMailable subclass without calling
parent::build() — the pattern shown in the README — meant none of the
Lettr setup ran: no transport selection, no X-Lettr-* headers, no
placeholder body. Delivery then aborted with "InvalidArgumentException:
Invalid view." because the mailable had no renderable content.

Move the setup into prepareLettrDelivery() and drive it from Laravel's
own delivery hook (prepareMailableForDelivery), with content() as a
second entry point. Both run regardless of what a subclass does with
build(), which stays for backwards compatibility.

The setup is idempotent: the withSymfonyMessage callback is registered
once, and everything it reads is resolved when the callback fires, so
the call order relative to template()/substitutionData() no longer
matters. The placeholder body is now only applied when the mailable has
no HTML body of its own, so a body set via Content(htmlString:) during
content hydration is no longer clobbered by the later setup pass.

Add delivery-level tests that send through the array transport and
assert the Symfony headers — 5 of the 8 fail against the previous
implementation. They live in a new file because ReadmeDocTest.php is
generated and would drop them on regeneration; that file only called
build() and inspected properties, which is why it passed on code that
could not be delivered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant