Skip to content

Major version bump#74

Open
peterfox wants to merge 25 commits into
mainfrom
2.x
Open

Major version bump#74
peterfox wants to merge 25 commits into
mainfrom
2.x

Conversation

@peterfox
Copy link
Copy Markdown
Collaborator

Summary

This is a major refactor that modernizes the package for Laravel 11+ and implements full RFC 8058 one-click unsubscribe compliance. The changes improve URL structure, add support for multiple notifiable model types, and enhance testing capabilities.

Key Changes

  • RFC 8058 Compliance: Added List-Unsubscribe-Post: List-Unsubscribe=One-Click header to all emails and updated the unsubscribe route to accept both GET and POST requests. POST requests return 204 No Content for one-click unsubscribe from email clients.

  • Morph Map Support: Replaced the single userModel configuration with Laravel's built-in morph map system. Unsubscribe URLs now use {subscriberType}/{subscriberId} instead of {subscriber}, allowing multiple notifiable model types to coexist. The controller resolves the model class using Relation::getMorphedModel().

  • Enum Support for Mailing Lists: Updated AppliesToMailingList::usesMailingList() return type to accept string|\BackedEnum, allowing type-safe mailing list definitions via backed enums.

  • Testing Improvements: Added FakeSubscriber class with assertion methods (assertUnsubscribedFromMailingList(), assertUnsubscribedFromAll(), assertNothingUnsubscribed()) and control methods (alwaysSubscribed(), alwaysUnsubscribed()) for easier testing without real handlers.

  • Documentation Overhaul: Completely rewrote README with clearer setup instructions, modern Laravel conventions (bootstrap/providers.php), and comprehensive examples including enum usage and testing patterns.

  • Removed Legacy Code: Eliminated the userModel() method and configuration from Subscriber and SubscribableApplicationServiceProvider, simplifying the API.

  • Updated URL Generation: Modified MailSubscriber::unsubscribeLink() to use getMorphClass() and getRouteKey() for proper model identification in signed URLs.

Implementation Details

  • The unsubscribe controller now validates the model type exists in the morph map before attempting to retrieve the model, improving security.
  • Mailing list values are extracted via a new mailingListValue() helper that handles both string and enum types.
  • The service provider stub was simplified to remove model configuration.
  • All tests were updated to use the new URL structure and removed references to userModel() configuration.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy

claude and others added 7 commits May 26, 2026 11:55
…iber::fake()

- Add List-Unsubscribe-Post header (RFC 8058) to all outgoing notification
  emails, satisfying Google/Yahoo bulk sender requirements enforced since 2024
- Register the unsubscribe route for both GET and POST; POST returns 204 No
  Content as required by RFC 8058 (no redirect)
- Broaden AppliesToMailingList::usesMailingList() return type to
  string|\BackedEnum so notifications can declare mailing lists as PHP enums;
  SubscriberMailChannel and MailSubscriber resolve enum values internally
- Add Testing\FakeSubscriber with assertUnsubscribedFromMailingList(),
  assertUnsubscribedFromAll(), assertNothingUnsubscribed(), alwaysSubscribed(),
  and alwaysUnsubscribed() helpers
- Add Subscriber::fake() on the facade to swap in FakeSubscriber for tests

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
The unsubscribe route now embeds the model's morph class alongside its
route key, removing the hard-wired userModel concept entirely.

Route: unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}

- MailSubscriber::unsubscribeLink() uses getMorphClass() / getRouteKey()
  so signed URLs automatically reference whichever Eloquent model the
  trait is applied to (User, Contact, Subscriber, etc.)
- UnsubscribeController resolves the model via Relation::getMorphedModel()
  (honours Laravel morph maps) and falls back to the raw class name;
  the resolved class must implement CanUnsubscribe or the request is
  rejected with 403, preventing abuse of arbitrary class names in URLs
- Subscriber::$userModel, Subscriber::userModel(), and the userModel()
  wiring in SubscribableApplicationServiceProvider are removed — model
  identity now comes from the URL, not package config
- FakeSubscriber::$userModel / userModel() removed accordingly

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
Covers RFC 8058 one-click unsubscribe, polymorphic model support with
morph maps, enum mailing lists, the updated handler API, and the new
Subscriber::fake() testing helper. Removes the obsolete userModel section.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
- run-tests: drop PHP 8.3 and Laravel 11 from the matrix
- phpstan: run against PHP 8.5

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
@peterfox peterfox changed the title Refactor unsubscribe routing to use morph maps and RFC 8058 compliance Major version bump May 26, 2026
claude and others added 18 commits May 26, 2026 14:39
SubscribableApplicationServiceProvider is removed. The published stub
is now a standard ServiceProvider that calls Subscriber::on*() directly
in boot() — no base class to extend, no abstract methods to implement.

Users open the published file, fill in the closures, and are done.
The DummyApplicationServiceProvider test fixture is updated to match,
and the test that covered only the abstract class pattern is deleted.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
- html.blade.php: switch → match for button colour, @component('mail::button')
  → <x-mail::button>, subcopy updated to use \$displayableActionUrl with
  break-all span, salutation comma moved inside the lang string
- text.blade.php: same switch → match and salutation fix; subcopy outputs
  \$displayableActionUrl as plain text; button keeps @component (text-safe)
- mail/html/message.blade.php: rewritten from @component('mail::layout') /
  @slot syntax to <x-mail::layout> / <x-slot:*> throughout; unsubscribe
  block still injected into the footer via the existing @slot mechanism
- mail/text/message.blade.php: same layout syntax update

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
html.blade.php and text.blade.php now use <x-mail::message> directly,
matching Laravel's own email.blade.php structure. The unsubscribe links
appear inline in the email body after the salutation, separated by a
horizontal rule, instead of being injected into the footer via a slot.

The subscriber::mail.html.message and subscriber::mail.text.message
layout wrappers are deleted — they were only needed to plumb the
unsubscribe slot through to the footer. The @component/@slot nesting
is gone entirely.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
…and NotificationSending listener

Notifications opt in to unsubscribe functionality by returning
SubscribableMailMessage::via($notifiable, $this) from toMail(). This
sets the RFC 8058 headers via a withSymfonyMessage() callback and injects
unsubscribeLink / unsubscribeLinkForAll into view data — without replacing
any Laravel internals.

Subscription gating (previously in the custom channel's send()) moves to
a NotificationSending event listener registered by SubscribableServiceProvider,
which cancels the send before toMail() is even called.

Also fixes phpunit.xml.dist to be compatible with PHPUnit 12 (removes legacy
<logging>/<coverage> sections that silently prevented test execution).

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
Moves all unsubscribe wiring logic out of SubscribableMailMessage and
into a composable Concerns\SubscribableNotification trait. This lets
any MailMessage subclass gain the same via() factory by simply using
the trait.

The markdown template is now set inside via() rather than as a class
property override, making it overridable per-call:

    MyMailMessage::via($notifiable, $this, 'my-package::custom')

SubscribableMailMessage remains as a ready-to-use concrete class.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
…PGRADE.md

Subscriber::legacyRoutes(User::class) registers a backward-compatible
route at the old URL shape (unsubscribe/{id}/{list?}) that resolves the
subscriber type from the provided model class. Since signed URL signatures
are over the URL path (not the route name), v1 links already in inboxes
validate correctly against the legacy route.

A where constraint on {subscriberType} prevents the new route from
accidentally matching numeric v1-style IDs: the pattern [^\d/][^/]* ensures
the first segment starts with a non-digit and contains no slashes.

Also adds UPGRADE.md covering all breaking changes, the route migration
strategy, and optional v2 improvements (enums, Subscriber::fake()).

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
…ped properties

final:
- Subscriber, SubscribableServiceProvider, FakeSubscriber
- UnsubscribeController, LegacyUnsubscribeController
- UserUnsubscribed, UserUnsubscribing

SubscribableMailMessage and the SubscribableNotification trait are left
open — the composable design depends on subclassing.

readonly / constructor property promotion:
- Events use public readonly constructor promotion; $user type corrected
  from the misleading Illuminate\Foundation\Auth\User to object
- UnsubscribeController and LegacyUnsubscribeController use private readonly
- Subscriber promotes $app to private readonly

Typed callable properties:
- Subscriber's five handler properties are now private \Closure|null
- parseHandler() returns \Closure via \Closure::fromCallable(), using
  Str::parseCallback()'s second argument for the default method name
- Invocation uses first-class callable style: ($this->onFoo)($args)

Other cleanup:
- $hander typo corrected to $handler throughout
- Missing return types added (routeName, checkSubscriptionStatus, etc.)
- Protected visibility on Subscriber/FakeSubscriber internals tightened
  to private (no subclassing possible on final classes)
- Redundant @param/@return docblocks removed where signatures suffice
- SubscribableServiceProvider::register() simplified to a short closure
- FakeSubscriber gains assertCheckedSubscriptionStatus() so tests that
  previously used Subscriber::shouldReceive() (incompatible with final)
  can still verify argument routing via the fake

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
…RFC 8058 body

- Make `legacySubscriberType` private with a public getter to prevent
  external mutation of the Subscriber singleton
- Add `throttle:60,1` middleware to both the main and legacy unsubscribe routes
- Validate RFC 8058 POST body (`List-Unsubscribe=One-Click`) in
  UnsubscribeController; return 400 for invalid requests
- Deduplicate `unsubscribeLink()` call in SubscribableNotification::via()
- Warn in stub that the route should not be inside the `web` middleware
  group to avoid CSRF token verification on one-click POST requests
- Update POST tests to send the correct RFC 8058 body; add test asserting
  400 is returned when the body is missing

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
…eters

Users can now pass a custom rate or disable throttling entirely via named
parameters, rather than having a hardcoded '60,1' rate:

  Subscriber::routes(throttle: '10,1');   // custom rate
  Subscriber::routes(throttle: false);    // disable

The default remains '60,1'. FakeSubscriber, facade docblock, and the
published stub are updated to match.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
- Add 'Route configuration' section covering throttle options (default,
  custom rate, disabled), the CSRF/middleware-group caveat, and the
  legacyRoutes() method for v1 compatibility with a link to UPGRADE.md
- Add assertCheckedSubscriptionStatus() to the testing assertions table

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
Two stubs are published via `vendor:publish --tag=subscriber-tests`:

- UnsubscribeRouteTest — covers GET and POST unsubscribe routes, RFC 8058
  body validation, and tampered-URL rejection
- SubscribableNotificationTest — covers subscription gating (subscribed
  sends, unsubscribed dropped) and view data presence in the mail message

Each stub uses Subscriber::fake() and is annotated with TODO markers where
the user substitutes their own model or notification class.

README Testing section updated with a scaffold install command and a table
describing what each stub covers.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
…y, composer alias

1. Extract SubscriberContract interface so that Subscriber::fake() is
   compatible with real HTTP route tests. Both Subscriber and FakeSubscriber
   implement the interface. Controllers type-hint SubscriberContract.
   The service provider aliases SubscriberContract -> Subscriber so that
   Facade::swap() transparently affects controller injection.

2. Add null guards with descriptive LogicException messages on all five
   action/handler methods in Subscriber. checkSubscriptionStatus() falls
   back to true (assume subscribed) when handlers are not registered,
   so subscription checks never fatal during early development.

3. Add getLegacySubscriberType() to FakeSubscriber to satisfy the contract.

4. Fix composer.json auto-alias: YlsIdeas\Facades\Subscriber ->
   YlsIdeas\SubscribableNotifications\Facades\Subscriber.

5. Replace Notification::fake() with Mail::fake() in
   SubscribableNotificationTest.stub. Notification::fake() bypasses the
   NotificationSending event where the subscription gate lives, so
   suppression tests would always pass regardless of subscription status.

6. Document the Mail::fake() requirement in the README Testing section.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
Subscriber::fake() was only calling Facade::swap(), which updates facade
resolution but leaves the container bindings for Subscriber::class and
SubscriberContract::class pointing at the real instance. Controllers that
receive SubscriberContract via constructor injection therefore continued
to get the real Subscriber after fake() was called.

Fix: explicitly bind the fake instance against both keys before swapping,
preceded by forgetInstance() calls to clear any cached resolutions. This
matches the pattern used by Notification::fake(), Event::fake(), etc.

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
…entity

When a test exercises the unsubscribe route via an HTTP request, the
controller loads a fresh model instance from the database. The test holds
a separate object reference to the same row. The previous === comparisons
in assertUnsubscribedFromAll/assertUnsubscribedFromMailingList/assertCheckedSubscriptionStatus
would always fail in this scenario, making the published stubs unusable as-is.

Changes:
- Add protected matches() helper that falls back to getMorphClass()+getKey()
  comparison for Eloquent models, matching value equality rather than identity
- Drop 'final' from FakeSubscriber so subclasses can override matches() if
  they use non-Eloquent notifiables with custom equality semantics
- Add getUnsubscribedFromMailingList(), getUnsubscribedFromAll(), and
  getSubscriptionStatusChecks() for tests that need to inspect raw records
- Add regression test that exercises Subscriber::fake() through a real
  HTTP route to catch this category of bug going forward

https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
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.

2 participants