Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-Clickheader 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
userModelconfiguration 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 usingRelation::getMorphedModel().Enum Support for Mailing Lists: Updated
AppliesToMailingList::usesMailingList()return type to acceptstring|\BackedEnum, allowing type-safe mailing list definitions via backed enums.Testing Improvements: Added
FakeSubscriberclass 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 fromSubscriberandSubscribableApplicationServiceProvider, simplifying the API.Updated URL Generation: Modified
MailSubscriber::unsubscribeLink()to usegetMorphClass()andgetRouteKey()for proper model identification in signed URLs.Implementation Details
mailingListValue()helper that handles both string and enum types.userModel()configuration.https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy