From a2b898ec17f71a98d0695acdc7e07fbd411ba2c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 11:55:44 +0000 Subject: [PATCH 01/25] feat: v2 - RFC 8058 one-click unsubscribe, enum mailing lists, Subscriber::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 --- composer.json | 2 +- src/Channels/SubscriberMailChannel.php | 15 ++- src/Contracts/AppliesToMailingList.php | 5 +- src/Controllers/UnsubscribeController.php | 5 + src/Facades/Subscriber.php | 9 ++ src/MailSubscriber.php | 8 +- src/Subscriber.php | 3 +- src/Testing/FakeSubscriber.php | 110 ++++++++++++++++++ tests/Channels/SubscriberMailChannelTest.php | 50 ++++++++ .../Controllers/UnsubscribeControllerTest.php | 49 ++++++++ tests/Support/DummyMailingList.php | 9 ++ .../DummyNotificationWithEnumMailingList.php | 26 +++++ tests/Testing/FakeSubscriberTest.php | 72 ++++++++++++ 13 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 src/Testing/FakeSubscriber.php create mode 100644 tests/Support/DummyMailingList.php create mode 100644 tests/Support/DummyNotificationWithEnumMailingList.php create mode 100644 tests/Testing/FakeSubscriberTest.php diff --git a/composer.json b/composer.json index 337454e..98d2308 100755 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "ylsideas/subscribable-notifications", - "description": "A Laravel package for adding unsubscribe links to notifications", + "description": "A Laravel package for adding RFC 8058 compliant unsubscribe links to notifications", "keywords": [ "ylsideas", "unsubscribable-notification", diff --git a/src/Channels/SubscriberMailChannel.php b/src/Channels/SubscriberMailChannel.php index 3f4982e..5d9f2de 100644 --- a/src/Channels/SubscriberMailChannel.php +++ b/src/Channels/SubscriberMailChannel.php @@ -47,7 +47,7 @@ public function send($notifiable, Notification $notification) if ($notifiable instanceof CanUnsubscribe && $message instanceof MailMessage) { if ($notification instanceof AppliesToMailingList) { $message->viewData['unsubscribeLink'] = $notifiable->unsubscribeLink( - $notification->usesMailingList() + $this->mailingListValue($notification) ); } $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); @@ -90,13 +90,24 @@ protected function buildMessage($mailMessage, $notifiable, $notification, $messa 'List-Unsubscribe', sprintf('<%s>', $notifiable->unsubscribeLink( $notification instanceof AppliesToMailingList - ? $notification->usesMailingList() + ? $this->mailingListValue($notification) : null )) ); + $mailMessage->getHeaders()->addTextHeader( + 'List-Unsubscribe-Post', + 'List-Unsubscribe=One-Click' + ); } } + private function mailingListValue(AppliesToMailingList $notification): string + { + $list = $notification->usesMailingList(); + + return $list instanceof \BackedEnum ? $list->value : $list; + } + /** * Build the notification's view. * diff --git a/src/Contracts/AppliesToMailingList.php b/src/Contracts/AppliesToMailingList.php index 023f77b..4ba6c3b 100644 --- a/src/Contracts/AppliesToMailingList.php +++ b/src/Contracts/AppliesToMailingList.php @@ -4,8 +4,5 @@ interface AppliesToMailingList { - /** - * @return string - */ - public function usesMailingList(): string; + public function usesMailingList(): string|\BackedEnum; } diff --git a/src/Controllers/UnsubscribeController.php b/src/Controllers/UnsubscribeController.php index f4d5a15..40e258e 100644 --- a/src/Controllers/UnsubscribeController.php +++ b/src/Controllers/UnsubscribeController.php @@ -59,6 +59,11 @@ public function __invoke(Request $request, $subscriber, ?string $mailingList = n event(new UserUnsubscribed($subscriber, $mailingList)); + // RFC 8058: one-click POST must not redirect; return 204 No Content + if ($request->isMethod('post')) { + return response('', 204); + } + return $this->subscriber->complete($subscriber, $mailingList); } } diff --git a/src/Facades/Subscriber.php b/src/Facades/Subscriber.php index 93776a8..148b596 100644 --- a/src/Facades/Subscriber.php +++ b/src/Facades/Subscriber.php @@ -4,6 +4,7 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Facade; +use YlsIdeas\SubscribableNotifications\Testing\FakeSubscriber; /** * Class Subscriber. @@ -29,4 +30,12 @@ protected static function getFacadeAccessor() { return \YlsIdeas\SubscribableNotifications\Subscriber::class; } + + public static function fake(): FakeSubscriber + { + $fake = new FakeSubscriber(); + static::swap($fake); + + return $fake; + } } diff --git a/src/MailSubscriber.php b/src/MailSubscriber.php index b3384f4..73cdfd9 100644 --- a/src/MailSubscriber.php +++ b/src/MailSubscriber.php @@ -27,11 +27,13 @@ public function unsubscribeLink(?string $mailingList = ''): string */ public function mailSubscriptionStatus(Notification $notification): bool { + $list = $notification instanceof AppliesToMailingList + ? $notification->usesMailingList() + : null; + return Subscriber::checkSubscriptionStatus( $this, - $notification instanceof AppliesToMailingList - ? $notification->usesMailingList() - : null + $list instanceof \BackedEnum ? $list->value : $list ); } } diff --git a/src/Subscriber.php b/src/Subscriber.php index 8da2293..89a16b7 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -58,7 +58,8 @@ public function __construct(Application $app) public function routes($router = null) { $router = $router ?? $this->app->make('router'); - $router->get( + $router->match( + ['GET', 'POST'], $this->uri, $this->hander ) diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php new file mode 100644 index 0000000..c09e001 --- /dev/null +++ b/src/Testing/FakeSubscriber.php @@ -0,0 +1,110 @@ +routeName; + } + + public function userModel(?string $model = null): ?string + { + if ($model) { + $this->userModel = $model; + + return null; + } + + return $this->userModel; + } + + public function onUnsubscribeFromMailingList($handler): void {} + + public function onUnsubscribeFromAllMailingLists($handler): void {} + + public function onCompletion($handler): void {} + + public function onCheckSubscriptionStatusOfAllMailingLists($handler): void {} + + public function onCheckSubscriptionStatusOfMailingList($handler): void {} + + public function unsubscribeFromMailingList($user, string $mailingList): void + { + $this->unsubscribedFromMailingList[] = ['user' => $user, 'list' => $mailingList]; + } + + public function unsubscribeFromAllMailingLists($user): void + { + $this->unsubscribedFromAll[] = $user; + } + + public function complete($user, ?string $mailingList = null): Response + { + return new Response('', 200); + } + + public function checkSubscriptionStatus($user, ?string $mailingList = null): bool + { + return $this->subscriptionStatus; + } + + public function alwaysSubscribed(): static + { + $this->subscriptionStatus = true; + + return $this; + } + + public function alwaysUnsubscribed(): static + { + $this->subscriptionStatus = false; + + return $this; + } + + public function assertUnsubscribedFromMailingList(mixed $user, string $mailingList): void + { + Assert::assertTrue( + collect($this->unsubscribedFromMailingList) + ->contains(fn ($item) => $item['user'] === $user && $item['list'] === $mailingList), + "Failed asserting that the user was unsubscribed from [{$mailingList}]." + ); + } + + public function assertUnsubscribedFromAll(mixed $user): void + { + Assert::assertTrue( + collect($this->unsubscribedFromAll)->contains($user), + 'Failed asserting that the user was unsubscribed from all mailing lists.' + ); + } + + public function assertNothingUnsubscribed(): void + { + Assert::assertEmpty( + $this->unsubscribedFromMailingList, + 'Failed asserting that no mailing list unsubscribes occurred.' + ); + Assert::assertEmpty( + $this->unsubscribedFromAll, + 'Failed asserting that no all-mail unsubscribes occurred.' + ); + } +} diff --git a/tests/Channels/SubscriberMailChannelTest.php b/tests/Channels/SubscriberMailChannelTest.php index 3d5fc8b..8b28116 100644 --- a/tests/Channels/SubscriberMailChannelTest.php +++ b/tests/Channels/SubscriberMailChannelTest.php @@ -12,6 +12,7 @@ use YlsIdeas\SubscribableNotifications\Tests\Support\DummyNotifiable; use YlsIdeas\SubscribableNotifications\Tests\Support\DummyNotifiableWithSubscriptions; use YlsIdeas\SubscribableNotifications\Tests\Support\DummyNotification; +use YlsIdeas\SubscribableNotifications\Tests\Support\DummyNotificationWithEnumMailingList; use YlsIdeas\SubscribableNotifications\Tests\Support\DummyNotificationWithMailingList; use YlsIdeas\SubscribableNotifications\Tests\Support\DummyNotificationWithQueuing; @@ -57,6 +58,14 @@ public function test_it_sends_mail_notifications_with_unsubscribe_links_for_mail $this->getHeaderContent($event->message, 'List-Unsubscribe') ); + $this->assertTrue( + $event->message->getHeaders()->has('List-Unsubscribe-Post') + ); + $this->assertEquals( + 'List-Unsubscribe=One-Click', + $this->getHeaderContent($event->message, 'List-Unsubscribe-Post') + ); + $content = $this->getContent($event->message); $this->assertStringContainsString( @@ -112,6 +121,14 @@ public function test_it_sends_mail_notifications_with_mailing_links_via_queues() $this->getHeaderContent($event->message, 'List-Unsubscribe') ); + $this->assertTrue( + $event->message->getHeaders()->has('List-Unsubscribe-Post') + ); + $this->assertEquals( + 'List-Unsubscribe=One-Click', + $this->getHeaderContent($event->message, 'List-Unsubscribe-Post') + ); + $content = $this->getContent($event->message); $this->assertStringContainsString( @@ -156,6 +173,14 @@ public function test_it_sends_mail_notifications_with_an_unsubscribe_link_for_al $this->getHeaderContent($event->message, 'List-Unsubscribe') ); + $this->assertTrue( + $event->message->getHeaders()->has('List-Unsubscribe-Post') + ); + $this->assertEquals( + 'List-Unsubscribe=One-Click', + $this->getHeaderContent($event->message, 'List-Unsubscribe-Post') + ); + $content = $this->getContent($event->message); $this->assertStringContainsString( @@ -295,6 +320,31 @@ public function test_it_uses_views_set_on_the_mail_message_from_the_notification }); } + public function test_it_resolves_enum_mailing_list_to_its_string_value() + { + Event::fake([ + MessageSending::class, + MessageSent::class, + ]); + + $notifiable = new DummyNotifiableWithSubscriptions(); + $notifiable->notify(new DummyNotificationWithEnumMailingList()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLink', $event->data); + $this->assertEquals( + 'https://testing.local/unsubscribe/newsletter', + $event->data['unsubscribeLink'] + ); + $this->assertEquals( + '', + $this->getHeaderContent($event->message, 'List-Unsubscribe') + ); + + return true; + }); + } + /** * @param \Swift_Message|Email $message * @return string diff --git a/tests/Controllers/UnsubscribeControllerTest.php b/tests/Controllers/UnsubscribeControllerTest.php index ee42a55..df6aaa0 100644 --- a/tests/Controllers/UnsubscribeControllerTest.php +++ b/tests/Controllers/UnsubscribeControllerTest.php @@ -165,4 +165,53 @@ public function test_it_fires_events_for_unsubscribing() Event::assertDispatched(UserUnsubscribing::class); Event::assertDispatched(UserUnsubscribed::class); } + + public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe() + { + $this->withoutExceptionHandling(); + + /** @var DummyUser $user */ + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + Subscriber::userModel(DummyUser::class); + + $called = false; + Subscriber::onUnsubscribeFromAllMailingLists(function ($user) use (&$called) { + $called = true; + }); + + $this->post($expectedUser->unsubscribeLink()) + ->assertNoContent(); + + $this->assertTrue($called); + } + + public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe_from_mailing_list() + { + $this->withoutExceptionHandling(); + + /** @var DummyUser $user */ + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + Subscriber::userModel(DummyUser::class); + + $called = false; + Subscriber::onUnsubscribeFromMailingList(function ($user, $list) use (&$called) { + $called = true; + $this->assertEquals('newsletter', $list); + }); + + $this->post($expectedUser->unsubscribeLink('newsletter')) + ->assertNoContent(); + + $this->assertTrue($called); + } } diff --git a/tests/Support/DummyMailingList.php b/tests/Support/DummyMailingList.php new file mode 100644 index 0000000..99d2700 --- /dev/null +++ b/tests/Support/DummyMailingList.php @@ -0,0 +1,9 @@ +line('The introduction to the notification.'); + } + + public function usesMailingList(): string|\BackedEnum + { + return DummyMailingList::Newsletter; + } +} diff --git a/tests/Testing/FakeSubscriberTest.php b/tests/Testing/FakeSubscriberTest.php new file mode 100644 index 0000000..dc96541 --- /dev/null +++ b/tests/Testing/FakeSubscriberTest.php @@ -0,0 +1,72 @@ +assertInstanceOf(FakeSubscriber::class, $fake); + } + + public function test_fake_swaps_the_underlying_implementation() + { + $fake = Subscriber::fake(); + + $this->assertSame($fake, Subscriber::getFacadeRoot()); + } + + public function test_it_records_mailing_list_unsubscribes() + { + $fake = Subscriber::fake(); + $user = new \stdClass(); + + $fake->unsubscribeFromMailingList($user, 'newsletter'); + + $fake->assertUnsubscribedFromMailingList($user, 'newsletter'); + } + + public function test_it_records_all_mail_unsubscribes() + { + $fake = Subscriber::fake(); + $user = new \stdClass(); + + $fake->unsubscribeFromAllMailingLists($user); + + $fake->assertUnsubscribedFromAll($user); + } + + public function test_assert_nothing_unsubscribed_passes_when_empty() + { + $fake = Subscriber::fake(); + + $fake->assertNothingUnsubscribed(); + } + + public function test_it_controls_subscription_status() + { + $fake = Subscriber::fake(); + + $fake->alwaysUnsubscribed(); + $this->assertFalse($fake->checkSubscriptionStatus(new \stdClass(), 'newsletter')); + + $fake->alwaysSubscribed(); + $this->assertTrue($fake->checkSubscriptionStatus(new \stdClass(), 'newsletter')); + } +} From f6625e740eb172ae345c9ec78662a86eff911710 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 11:56:15 +0000 Subject: [PATCH 02/25] chore: ignore .phpunit.cache directory https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy --- .gitignore | 1 + .phpunit.cache/test-results | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .phpunit.cache/test-results diff --git a/.gitignore b/.gitignore index 4dc194b..05b0f86 100755 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ docs vendor coverage .phpunit.result.cache +.phpunit.cache .php_cs.cache .php-cs-fixer.cache diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results deleted file mode 100644 index 7a735be..0000000 --- a/.phpunit.cache/test-results +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"defects":{"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribeApplicationServiceProviderTest::test_it_can_be_configured_to_loads_routes":5},"times":{"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_with_unsubscribe_links_for_mailing_lists":0.107,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_with_mailing_links_via_queues":0.008,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_with_an_unsubscribe_link_for_all_emails":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_normally_otherwise":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_handles_mailables_as_per_inherited_behavior":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_checks_if_a_notifiable_is_subscribed_to_receive_the_notification":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_does_not_send_mail_if_there_is_no_email_to_route_to":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_uses_views_set_on_the_mail_message_from_the_notification":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_unsubscribes_users_from_all_mailing_lists":0.025,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_unsubscribes_users_from_a_mailing_list":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_uses_the_subscriber_to_redirect_the_user_after_completion":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_aborts_if_the_target_model_does_not_exist":0.004,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_fires_events_for_unsubscribing":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Events\\UserUnsubscribedTest::test_it_can_be_initialised_with_parameters":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Events\\UserUnsubscribingTest::test_it_can_be_initialised_with_parameters":0,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_generates_a_signed_url_for_users_to_unsubscribe":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_generates_a_signed_url_for_users_to_unsubscribe_from_a_mailing_list":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_can_check_its_subscription_status_for_all_mailing_lists":0.006,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_can_check_its_subscription_status_for_one_mailing_list":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribableServiceProviderTest::test_it_can_publish_views":0.008,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribableServiceProviderTest::test_it_loads_views":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribableServiceProviderTest::test_it_can_publish_an_application_service_provider":0.001,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribeApplicationServiceProviderTest::test_it_can_be_configured_to_loads_routes":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribeApplicationServiceProviderTest::test_it_can_be_configured_to_not_loads_routes":0.001,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_all_mailing_lists_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_all_mailing_lists_via_string":0.001,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_a_mailing_list_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_a_mailing_list_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_generating_a_response_for_unsubscribing_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_generating_a_response_for_unsubscribing_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_a_mailing_list_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_a_mailing_list_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_all_mailing_lists_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_all_mailing_lists_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_can_provide_a_user_model":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_can_configure_a_user_model":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_can_configure_a_route_for_the_unsubscribe_controller":0}} \ No newline at end of file From 3f7ebbc32f0eba52f7fdbd6b42d89fb669707d33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 12:58:57 +0000 Subject: [PATCH 03/25] =?UTF-8?q?feat:=20polymorphic=20subscriber=20resolu?= =?UTF-8?q?tion=20=E2=80=94=20works=20with=20any=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Controllers/UnsubscribeController.php | 17 +++++++++---- src/Facades/Subscriber.php | 1 - src/MailSubscriber.php | 8 +++++-- ...SubscribableApplicationServiceProvider.php | 22 ----------------- src/Subscriber.php | 21 +--------------- src/Testing/FakeSubscriber.php | 13 ---------- stubs/SubscribableServiceProvider.stub | 3 --- .../Controllers/UnsubscribeControllerTest.php | 14 ++++------- tests/MailSubscriberTest.php | 16 +++++++++---- ...ubscribeApplicationServiceProviderTest.php | 1 - tests/SubscriberTest.php | 24 +------------------ .../DummyApplicationServiceProvider.php | 2 -- 12 files changed, 37 insertions(+), 105 deletions(-) diff --git a/src/Controllers/UnsubscribeController.php b/src/Controllers/UnsubscribeController.php index 40e258e..9c835c4 100644 --- a/src/Controllers/UnsubscribeController.php +++ b/src/Controllers/UnsubscribeController.php @@ -2,9 +2,11 @@ namespace YlsIdeas\SubscribableNotifications\Controllers; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; +use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribed; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribing; use YlsIdeas\SubscribableNotifications\Subscriber; @@ -33,16 +35,23 @@ public function __construct(Subscriber $subscriber) * Handle the incoming request. * * @param Request $request - * @param mixed $subscriber + * @param string $subscriberType + * @param mixed $subscriberId * @param string|null $mailingList * @return Response */ - public function __invoke(Request $request, $subscriber, ?string $mailingList = null) + public function __invoke(Request $request, string $subscriberType, $subscriberId, ?string $mailingList = null) { - $model = new $this->subscriber->userModel(); + $modelClass = Relation::getMorphedModel($subscriberType) ?? $subscriberType; + + if (! class_exists($modelClass) || ! is_a($modelClass, CanUnsubscribe::class, true)) { + abort(403, __('Could not process unsubscribe request')); + } + + $model = new $modelClass(); $subscriber = $model - ->where($model->getRouteKeyName(), $subscriber) + ->where($model->getRouteKeyName(), $subscriberId) ->first(); if (! $subscriber) { diff --git a/src/Facades/Subscriber.php b/src/Facades/Subscriber.php index 148b596..18e80e5 100644 --- a/src/Facades/Subscriber.php +++ b/src/Facades/Subscriber.php @@ -13,7 +13,6 @@ * * @method static void routes() * @method static string routeName() - * @method static mixed userModel(string $model = null) * @method static void onCompletion(callable|string $handler) * @method static void onUnsubscribeFromMailingList(callable|string $handler) * @method static void onUnsubscribeFromAllMailingLists(callable|string $handler) diff --git a/src/MailSubscriber.php b/src/MailSubscriber.php index 73cdfd9..902fc95 100644 --- a/src/MailSubscriber.php +++ b/src/MailSubscriber.php @@ -13,11 +13,15 @@ trait MailSubscriber * @param string|null $mailingList * @return string */ - public function unsubscribeLink(?string $mailingList = ''): string + public function unsubscribeLink(?string $mailingList = null): string { return URL::signedRoute( Subscriber::routeName(), - ['subscriber' => $this, 'mailingList' => $mailingList] + [ + 'subscriberType' => $this->getMorphClass(), + 'subscriberId' => $this->getRouteKey(), + 'mailingList' => $mailingList, + ] ); } diff --git a/src/SubscribableApplicationServiceProvider.php b/src/SubscribableApplicationServiceProvider.php index 86fdac0..9ae4108 100644 --- a/src/SubscribableApplicationServiceProvider.php +++ b/src/SubscribableApplicationServiceProvider.php @@ -11,21 +11,12 @@ abstract class SubscribableApplicationServiceProvider extends ServiceProvider */ protected $loadRoutes = true; - /** - * @var string - */ - protected $model = null; - public function boot() { if ($this->loadRoutes === true) { $this->loadRoutes(); } - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::userModel( - $this->userModel() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onUnsubscribeFromMailingList( $this->onUnsubscribeFromMailingList() ); @@ -43,19 +34,6 @@ public function boot() ); } - protected function userModel() - { - if ($this->model != null) { - return $this->model; - } - - if (version_compare($this->app->version(), '8.0.0', '>=')) { - return '\App\Models\User'; - } - - return '\App\User'; - } - public function loadRoutes() { \YlsIdeas\SubscribableNotifications\Facades\Subscriber::routes(); diff --git a/src/Subscriber.php b/src/Subscriber.php index 89a16b7..d0db975 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -11,7 +11,7 @@ class Subscriber /** * @var string */ - public $uri = 'unsubscribe/{subscriber}/{mailingList?}'; + public $uri = 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'; /** * @var string */ @@ -20,10 +20,6 @@ class Subscriber * @var string */ public $routeName = 'unsubscribe'; - /** - * @var string - */ - public $userModel = '\App\Models\User'; /** * @var callable */ @@ -74,21 +70,6 @@ public function routeName() return $this->routeName; } - /** - * @param string|null $model - * @return string|null - */ - public function userModel(?string $model = null) - { - if ($model) { - $this->userModel = $model; - - return null; - } - - return $this->userModel; - } - /** * @param string|callable $handler * @throws \Illuminate\Contracts\Container\BindingResolutionException diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php index c09e001..b1b5875 100644 --- a/src/Testing/FakeSubscriber.php +++ b/src/Testing/FakeSubscriber.php @@ -7,8 +7,6 @@ class FakeSubscriber { - public string $userModel = '\App\Models\User'; - public string $routeName = 'unsubscribe'; protected array $unsubscribedFromMailingList = []; @@ -24,17 +22,6 @@ public function routeName(): string return $this->routeName; } - public function userModel(?string $model = null): ?string - { - if ($model) { - $this->userModel = $model; - - return null; - } - - return $this->userModel; - } - public function onUnsubscribeFromMailingList($handler): void {} public function onUnsubscribeFromAllMailingLists($handler): void {} diff --git a/stubs/SubscribableServiceProvider.stub b/stubs/SubscribableServiceProvider.stub index 7baeb38..71acc9a 100644 --- a/stubs/SubscribableServiceProvider.stub +++ b/stubs/SubscribableServiceProvider.stub @@ -6,9 +6,6 @@ use YlsIdeas\SubscribableNotifications\SubscribableApplicationServiceProvider; class SubscribableServiceProvider extends SubscribableApplicationServiceProvider { - /** - * @var bool - */ protected $loadRoutes = true; /** diff --git a/tests/Controllers/UnsubscribeControllerTest.php b/tests/Controllers/UnsubscribeControllerTest.php index df6aaa0..0690c58 100644 --- a/tests/Controllers/UnsubscribeControllerTest.php +++ b/tests/Controllers/UnsubscribeControllerTest.php @@ -48,8 +48,6 @@ public function test_it_unsubscribes_users_from_all_mailing_lists() 'password' => 'test', ]); - Subscriber::userModel(DummyUser::class); - Subscriber::onUnsubscribeFromAllMailingLists( function ($user) use (&$expected, $expectedUser) { $expected = true; @@ -77,8 +75,6 @@ public function test_it_unsubscribes_users_from_a_mailing_list() 'password' => 'test', ]); - Subscriber::userModel(DummyUser::class); - Subscriber::onUnsubscribeFromMailingList( function ($user, $mailingList) use (&$expected, $expectedUser) { $expected = true; @@ -137,7 +133,11 @@ function () use (&$notExpected) { $this->get( URL::signedRoute( Subscriber::routeName(), - ['subscriber' => 1, 'mailingList' => 'test'] + [ + 'subscriberType' => DummyUser::class, + 'subscriberId' => 999, + 'mailingList' => 'test', + ] ) ) ->assertStatus(403); @@ -177,8 +177,6 @@ public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe() 'password' => 'test', ]); - Subscriber::userModel(DummyUser::class); - $called = false; Subscriber::onUnsubscribeFromAllMailingLists(function ($user) use (&$called) { $called = true; @@ -201,8 +199,6 @@ public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe_from_ 'password' => 'test', ]); - Subscriber::userModel(DummyUser::class); - $called = false; Subscriber::onUnsubscribeFromMailingList(function ($user, $list) use (&$called) { $called = true; diff --git a/tests/MailSubscriberTest.php b/tests/MailSubscriberTest.php index aeb6051..4238d21 100644 --- a/tests/MailSubscriberTest.php +++ b/tests/MailSubscriberTest.php @@ -32,7 +32,7 @@ protected function getPackageProviders($app) public function test_it_generates_a_signed_url_for_users_to_unsubscribe() { - Route::get('unsubscribe/{subscriber}/{mailingList?}', function () { + Route::get('unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}', function () { })->name('unsubscribe'); /** @var DummyUser $user */ @@ -45,16 +45,18 @@ public function test_it_generates_a_signed_url_for_users_to_unsubscribe() $url = $user->unsubscribeLink(); - $this->assertEquals( - URL::signedRoute('unsubscribe', ['subscriber' => 1]), + URL::signedRoute('unsubscribe', [ + 'subscriberType' => $user->getMorphClass(), + 'subscriberId' => 1, + ]), $url ); } public function test_it_generates_a_signed_url_for_users_to_unsubscribe_from_a_mailing_list() { - Route::get('unsubscribe/{subscriber}/{mailingList?}', function () { + Route::get('unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}', function () { })->name('unsubscribe'); /** @var DummyUser $user */ @@ -68,7 +70,11 @@ public function test_it_generates_a_signed_url_for_users_to_unsubscribe_from_a_m $url = $user->unsubscribeLink('test'); $this->assertEquals( - URL::signedRoute('unsubscribe', ['subscriber' => 1, 'mailingList' => 'test']), + URL::signedRoute('unsubscribe', [ + 'subscriberType' => $user->getMorphClass(), + 'subscriberId' => 1, + 'mailingList' => 'test', + ]), $url ); } diff --git a/tests/SubscribeApplicationServiceProviderTest.php b/tests/SubscribeApplicationServiceProviderTest.php index 9124bd2..e3a1159 100644 --- a/tests/SubscribeApplicationServiceProviderTest.php +++ b/tests/SubscribeApplicationServiceProviderTest.php @@ -11,7 +11,6 @@ class SubscribeApplicationServiceProviderTest extends TestCase public function test_it_can_be_configured_to_loads_routes() { Subscriber::shouldReceive('routes'); - Subscriber::shouldReceive('userModel'); Subscriber::shouldReceive('onUnsubscribeFromMailingList'); Subscriber::shouldReceive('onUnsubscribeFromAllMailingLists'); Subscriber::shouldReceive('onCompletion'); diff --git a/tests/SubscriberTest.php b/tests/SubscriberTest.php index f8135db..6861ed3 100755 --- a/tests/SubscriberTest.php +++ b/tests/SubscriberTest.php @@ -281,28 +281,6 @@ public function test_it_handles_checking_subscription_status_of_all_mailing_list $this->assertTrue($subscriber->checkSubscriptionStatus($expectedUser)); } - public function test_it_can_provide_a_user_model() - { - $subscriber = new Subscriber($this->app); - $subscriber->userModel = \YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class; - - $this->assertEquals( - \YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class, - $subscriber->userModel() - ); - } - - public function test_it_can_configure_a_user_model() - { - $subscriber = new Subscriber($this->app); - $subscriber->userModel(\YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class); - - $this->assertEquals( - \YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class, - $subscriber->userModel - ); - } - public function test_it_can_configure_a_route_for_the_unsubscribe_controller() { $subscriber = new Subscriber($this->app); @@ -316,6 +294,6 @@ public function test_it_can_configure_a_route_for_the_unsubscribe_controller() $this->assertTrue($router->getRoutes()->hasNamedRoute('unsubscribe')); /** @var \Illuminate\Routing\Route $route */ $route = $router->getRoutes()->getByName('unsubscribe'); - $this->assertEquals($route->uri, 'unsubscribe/{subscriber}/{mailingList?}'); + $this->assertEquals($route->uri, 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'); } } diff --git a/tests/Support/DummyApplicationServiceProvider.php b/tests/Support/DummyApplicationServiceProvider.php index bc54d9d..08c2d14 100644 --- a/tests/Support/DummyApplicationServiceProvider.php +++ b/tests/Support/DummyApplicationServiceProvider.php @@ -6,8 +6,6 @@ class DummyApplicationServiceProvider extends SubscribableApplicationServiceProvider { - protected $model = DummyUser::class; - protected $loadRoutes = true; public function shouldLoadRoutes($shouldLoad = false) From 0eba500bce27dc3fc4f0a2167ae9bd05a7dc29f6 Mon Sep 17 00:00:00 2001 From: peterfox <1716506+peterfox@users.noreply.github.com> Date: Tue, 26 May 2026 12:59:11 +0000 Subject: [PATCH 04/25] Fix styling --- src/Testing/FakeSubscriber.php | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php index b1b5875..0c3eaea 100644 --- a/src/Testing/FakeSubscriber.php +++ b/src/Testing/FakeSubscriber.php @@ -15,22 +15,34 @@ class FakeSubscriber protected bool $subscriptionStatus = true; - public function routes($router = null): void {} + public function routes($router = null): void + { + } public function routeName(): string { return $this->routeName; } - public function onUnsubscribeFromMailingList($handler): void {} + public function onUnsubscribeFromMailingList($handler): void + { + } - public function onUnsubscribeFromAllMailingLists($handler): void {} + public function onUnsubscribeFromAllMailingLists($handler): void + { + } - public function onCompletion($handler): void {} + public function onCompletion($handler): void + { + } - public function onCheckSubscriptionStatusOfAllMailingLists($handler): void {} + public function onCheckSubscriptionStatusOfAllMailingLists($handler): void + { + } - public function onCheckSubscriptionStatusOfMailingList($handler): void {} + public function onCheckSubscriptionStatusOfMailingList($handler): void + { + } public function unsubscribeFromMailingList($user, string $mailingList): void { From 7a15fd7c96c269e556dae0e45daaccb9f9e0f95b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:14:48 +0000 Subject: [PATCH 05/25] docs: rewrite README for v2 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 --- README.md | 392 ++++++++++++++++++++++++++---------------------------- 1 file changed, 190 insertions(+), 202 deletions(-) diff --git a/README.md b/README.md index 800e43e..c200068 100755 --- a/README.md +++ b/README.md @@ -6,321 +6,313 @@ [![Total Downloads](https://img.shields.io/packagist/dt/ylsideas/subscribable-notifications.svg?style=flat-square)](https://packagist.org/packages/ylsideas/subscribable-notifications) [![Laravel Compatibility](https://badge.laravel.cloud/badge/ylsideas/subscribable-notifications?style=flat)](https://packagist.org/packages/ylsideas/subscribable-notifications) -This package has been designed to help you handle email unsubscribes with as little as 5 minutes setup. After installing -your notifications sent over email should now be delivered with unsubscribe links in the footer and as a mail header -which email clients can present to the user for quicker unsubscribing. It can also handle resolving the unsubscribing -of the user through a signed route/controller. +Handle email unsubscribes with minimal setup. The package injects unsubscribe links into notification emails, provides a signed unsubscribe route and controller, and fully complies with [RFC 8058](https://www.rfc-editor.org/rfc/rfc8058) one-click unsubscribe — required by Gmail and Yahoo for bulk senders since 2024. -## Installation +Every email sent through the package will include both headers automatically: + +``` +List-Unsubscribe: +List-Unsubscribe-Post: List-Unsubscribe=One-Click +``` -You can install the package via composer: +The unsubscribe route accepts `GET` (browser link) and `POST` (one-click from email clients), so users can unsubscribe without ever opening a browser. + +## Requirements + +- PHP 8.3+ +- Laravel 11, 12, or 13 + +## Installation ```bash -composer require ylsideas/subscribable-notifications +composer require ylsideas/subscribable-notifications:^2.0 ``` -Optionally to make use of the built in unsubscribing handler you can publish the application service -provider. If you wish to implement your own unsubscribing process and only insert unsubscribe links into -your notifications, you can forgo doing this. +Publish the application service provider stub: ```bash php artisan vendor:publish --tag=subscriber-provider ``` -This will create a `\App\Providers\SubscriberServiceProvider` class which you will need to register -in `config/app.php`. +This creates `App\Providers\SubscribableServiceProvider`. Register it in `bootstrap/providers.php` (Laravel 11+): ```php -'providers' => [ - ... - - /* - * Package Service Providers... - */ - \App\Providers\SubscribableServiceProvider::class, - - ... -] +return [ + App\Providers\AppServiceProvider::class, + App\Providers\SubscribableServiceProvider::class, +]; ``` -After this you can configure your unsubscribe handlers quickly as methods within the service provider that return the closures. +Or in `config/app.php` for older projects: -The package itself does not determine how you store or evaluate your users' subscribed state. Instead -it provides hooks in which to handle that. +```php +'providers' => [ + // ... + App\Providers\SubscribableServiceProvider::class, +], +``` -## Usage +## Setup + +### 1. Apply the trait to your notifiable model -First off you must implement the `YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe` interface -on your notifiable User model. You can also apply the `YlsIdeas\SubscribableNotifications\MailSubscriber` trait -which will implement this for you to automatically provide signed urls for the unsubscribe controller provided -by this library. +The `MailSubscriber` trait can be applied to **any Eloquent model** — not just `User`. The package uses Laravel's [morph map](https://laravel.com/docs/eloquent-relationships#custom-polymorphic-types) to identify models in signed URLs, so it works with multiple notifiable types side-by-side. -``` php +```php use YlsIdeas\SubscribableNotifications\MailSubscriber; use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; -class User implements CanUnsubscribe +class User extends Authenticatable implements CanUnsubscribe, CheckSubscriptionStatusBeforeSendingNotifications { use Notifiable, MailSubscriber; } ``` -### Implementing your own unsubscribe links - -If you wish to implement your own completely different `unsubscribeLink()` method you can. - -``` php -use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +You can apply it to any model that receives notifications: -class User implements CanUnsubscribe +```php +class Contact extends Model implements CanUnsubscribe, CheckSubscriptionStatusBeforeSendingNotifications { - use Notifiable; - - public function unsubscribeLink(?string $mailingList = ''): string - { - return URL::signedRoute( - 'sorry-to-see-you-go', - ['subscriber' => $this, 'mailingList' => $mailingList], - now()->addDays(1) - ); - } + use Notifiable, MailSubscriber; } ``` -### Implementing notifications as part of a mailing list +### 2. Register a morph map (recommended) + +Without a morph map, the full class name appears in unsubscribe URLs. Registering aliases keeps URLs short and decouples them from your class names: + +```php +// In AppServiceProvider::boot() +use Illuminate\Database\Eloquent\Relations\Relation; + +Relation::morphMap([ + 'user' => \App\Models\User::class, + 'contact' => \App\Models\Contact::class, +]); +``` + +With this in place, a `User` unsubscribe URL looks like: + +``` +https://example.com/unsubscribe/user/42?signature=... +``` + +Without it, the full class name is used instead. -If you wish to apply specific mailing lists to notifications you need to implement the -`YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList` on those notifications. -This will put two unsubscribe links into your emails generated from those notifications. -One for all emails and one for only that type of email. +## Usage -``` php +### Sending notifications with unsubscribe links + +No changes are needed to your notifications. Once the trait is on the model, every email notification sent to that model will automatically include unsubscribe links in the footer and the RFC 8058 headers. + +### Mailing list notifications + +Implement `AppliesToMailingList` on a notification to include a second, list-specific unsubscribe link alongside the global one. + +You can use a plain string: + +```php use YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList; -class Welcome extends Notification implements AppliesToMailingList +class WeeklyDigest extends Notification implements AppliesToMailingList { - ... - public function usesMailingList(): string { - return 'weekly-updates'; + return 'weekly-digest'; } - - ... } ``` -### Using the full unsubscribing workflow - -Using the `App\Providers\SubscriberServiceProvider` you can set up simple hooks to handle -unsubscribing the user from all future emails. This package doesn't determine how you should -store that record of opting out of future emails. Instead you provide functions in the provider -which will be called. The following are just examples of what you can do. +Or a backed enum (recommended for type safety): -#### Implementing an unsubscribe hook for a specific mailing list - -This handler will be called if a user links a link through to unsubscribe for a specific mailing list. +```php +enum MailingList: string +{ + case WeeklyDigest = 'weekly-digest'; + case ProductUpdates = 'product-updates'; +} -``` php -public class SubscriberServiceProvider +class WeeklyDigest extends Notification implements AppliesToMailingList { - ... - - public function onUnsubscribeFromMailingList() + public function usesMailingList(): string|\BackedEnum { - return function ($user, $mailingList) { - $user->mailing_lists = $user->mailing_lists->put($mailingList, false); - $user->save(); - }; + return MailingList::WeeklyDigest; } - - ... } ``` -#### Implementing an unsubscribe hook for all emails +### Configuring the unsubscribe handlers + +The published `App\Providers\SubscribableServiceProvider` has five methods to implement. Each returns a closure (or a `Class@method` string) that will be called at the appropriate point in the unsubscribe flow. -This handler will be called if the user has clicked through to the link to unsubscribe from all future emails. +```php +use YlsIdeas\SubscribableNotifications\SubscribableApplicationServiceProvider; -``` php -public class SubscriberServiceProvider +class SubscribableServiceProvider extends SubscribableApplicationServiceProvider { - ... - + // Called when a user unsubscribes from a specific mailing list + public function onUnsubscribeFromMailingList() + { + return function ($notifiable, string $mailingList) { + $notifiable->subscriptions()->where('list', $mailingList)->delete(); + }; + } + + // Called when a user unsubscribes from all emails public function onUnsubscribeFromAllMailingLists() { - return function ($user) { - $user->unsubscribed_at = now(); - $user->save(); + return function ($notifiable) { + $notifiable->update(['unsubscribed_at' => now()]); }; } - - ... -} -``` -#### Implementing an unsubscribe response + // Called after unsubscribing to determine the browser response (GET requests only) + public function onCompletion() + { + return function ($notifiable, ?string $mailingList) { + return redirect()->route('unsubscribe.confirmed'); + }; + } -The completion handler will be called after a user is unsubscribed, allowing you to customise where the user is -redirected to or if you want to maybe show a further form even. + // Return true if the notifiable is subscribed to a specific list + public function onCheckSubscriptionStatusOfMailingList() + { + return function ($notifiable, string $mailingList) { + return $notifiable->subscriptions()->where('list', $mailingList)->exists(); + }; + } -``` php -public class SubscriberServiceProvider -{ - ... - - public function onCompletion() + // Return true if the notifiable has not globally unsubscribed + public function onCheckSubscriptionStatusOfAllMailingLists() { - return function ($user, $mailingList) { - return view('confirmation') - ->with('alert', 'You\'re not unsubscribed'); + return function ($notifiable) { + return $notifiable->unsubscribed_at === null; }; } - - ... -} +} ``` -### Dedicated handler +You may also return a `Class@method` string and the class will be resolved from the service container: -You may also provide a string in the format of `class@method` that the subscriber class will use to grab the class -from the service container and then call the specified method on if you want to do something more custom. +```php +public function onUnsubscribeFromAllMailingLists() +{ + return \App\Handlers\UnsubscribeHandler::class . '@handleAll'; +} +``` + +### Blocking sends for unsubscribed users + +To prevent notifications being sent to users who have opted out, implement `CheckNotifiableSubscriptionStatus` on the notification: ```php -public class SubscriberServiceProvider +use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; + +class WeeklyDigest extends Notification implements AppliesToMailingList, CheckNotifiableSubscriptionStatus { - ... - - public function onUnsubscribeFromAllMailingLists() + public function checkMailSubscriptionStatus(): bool { - return '\App\UnsubscribeHandler@handleUnsubscribing'; + return true; } - - ... } ``` -### Checking if a notification should be sent per the subscription - -You can also add hooks to check if a user should receive notifications for a mailing -list or for all mail notifications. +When this returns `true`, the channel checks `$notifiable->mailSubscriptionStatus($notification)` before sending. The `MailSubscriber` trait implements this automatically using your configured handlers. If both the mailing-list check and the all-mail check return `true`, the email sends; otherwise it is silently dropped. -To do this you need to make sure your user has the -`YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications` interface -implemented. The `YlsIdeas\SubscribableNotifications\MailSubscriber` trait will implement this for you to use the -built in Subscriber handlers. +### Custom unsubscribe link -If you want to implement a method yourself to check the subscription you could also just implement the method yourself -like in the example below. +If you implement `CanUnsubscribe` directly instead of using the `MailSubscriber` trait, generate your own signed URL: -``` php +```php +use Illuminate\Support\Facades\URL; use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; -use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -class User implements CanUnsubscribe, CheckSubscriptionStatusBeforeSendingNotifications +class User extends Authenticatable implements CanUnsubscribe { use Notifiable; - - - public function mailSubscriptionStatus(Notification $notification) : bool + + public function unsubscribeLink(?string $mailingList = null): string { - return Subscriber::checkSubscriptionStatus( - $this, - $notification instanceof AppliesToMailingList - ? $notification->usesMailingList() - : null + return URL::signedRoute( + Subscriber::routeName(), + [ + 'subscriberType' => $this->getMorphClass(), + 'subscriberId' => $this->getRouteKey(), + 'mailingList' => $mailingList, + ] ); } } ``` -Then you need to implement the -`YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus` interface on the notifications -that should trigger a check of the subscription status of the user it's being sent to. Then you just need to return -`true` if the subscription status should be checked. +### Customising the email templates -``` php -use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; +The default templates inject a small unsubscribe block into the footer of all notification emails. Publish them to customise: -class Welcome extends Notification implements CheckNotifiableSubscriptionStatus -{ - ... - - public function checkMailSubscriptionStatus() : bool - { - return true; - } - - ... -} +```bash +php artisan vendor:publish --tag=subscriber-views ``` -To use the functionality you then need to add your own Subscription check hooks. These hooks can be implemented -as you see fit. +This creates `resources/views/vendor/subscriber/html.blade.php` and `text.blade.php`. -``` php -public class SubscriberServiceProvider -{ - ... +## Testing - public function onCheckSubscriptionStatusOfMailingList() - { - return function ($user, $mailingList) { - return $user->mailing_lists->get($mailingList, false); - }; - } +Use `Subscriber::fake()` in your tests to swap in a fake implementation and make assertions without needing real handlers configured: - public function onCheckSubscriptionStatusOfAllMailingLists() - { - return function ($user) { - return $user->unsubscribed_at === null; - }; - } - - ... -} -``` +```php +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -### Customising the email templates +it('unsubscribes the user from the newsletter', function () { + $fake = Subscriber::fake(); + $user = User::factory()->create(); -Out of the box the emails generated use the same templates except that they -inject a small bit of text into the footer of the emails. If you wish you customise -the templates further you may publish the views. + $this->get($user->unsubscribeLink('newsletter')); -```bash -php artisan vendor:publish --tag=subscriber-views + $fake->assertUnsubscribedFromMailingList($user, 'newsletter'); +}); + +it('unsubscribes the user from all emails', function () { + $fake = Subscriber::fake(); + $user = User::factory()->create(); + + $this->get($user->unsubscribeLink()); + + $fake->assertUnsubscribedFromAll($user); +}); ``` -This will create a `resources/views/vendor/subscriber` folder containing both `html.blade.php` -and `text.blade.php` which can be customised. These will then be the defaults used by the -notification mail channel. +Available assertions: -### Customising the User Model +| Method | Description | +|--------|-------------| +| `assertUnsubscribedFromMailingList($notifiable, $list)` | Assert the notifiable was unsubscribed from a specific list | +| `assertUnsubscribedFromAll($notifiable)` | Assert the notifiable was globally unsubscribed | +| `assertNothingUnsubscribed()` | Assert no unsubscribe actions occurred | -If you are using a different User model than the one found in `app/Models/User.php` or -`app/Users.php` for Laravel 7 and earlier you can change this by calling. It's suggested you -do this in the boot method of the `SubscriberServiceProvider`. +To control subscription status checks in feature tests: ```php -Subscriber::userModel('App\Models\User'); +Subscriber::fake()->alwaysUnsubscribed(); // all checkSubscriptionStatus calls return false +Subscriber::fake()->alwaysSubscribed(); // all checkSubscriptionStatus calls return true (default) ``` -### Testing +## Running the test suite -``` bash +```bash composer test ``` -### Changelog +## Changelog -Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. ## Contributing Please see [CONTRIBUTING](CONTRIBUTING.md) for details. -### Security +## Security If you discover any security related issues, please email peter.fox@ylsideas.co instead of using the issue tracker. @@ -332,7 +324,3 @@ If you discover any security related issues, please email peter.fox@ylsideas.co ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. - -## Laravel Package Boilerplate - -This package was generated using the [Laravel Package Boilerplate](https://laravelpackageboilerplate.com). From c2ccb730969076f99f077bbd160c0bdb242b9468 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:43:17 +0000 Subject: [PATCH 06/25] chore: bump minimum requirements to PHP 8.4 and Laravel 12+ https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy --- README.md | 4 ++-- composer.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c200068..c23111b 100755 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ The unsubscribe route accepts `GET` (browser link) and `POST` (one-click from em ## Requirements -- PHP 8.3+ -- Laravel 11, 12, or 13 +- PHP 8.4+ +- Laravel 12 or 13 ## Installation diff --git a/composer.json b/composer.json index 98d2308..27d51bd 100755 --- a/composer.json +++ b/composer.json @@ -17,11 +17,11 @@ } ], "require": { - "php": "^8.3", - "illuminate/contracts": "13.*|12.*|11.*" + "php": "^8.4", + "illuminate/contracts": "13.*|12.*" }, "require-dev": { - "orchestra/testbench": "^9.17.0|^10.0|^11.0", + "orchestra/testbench": "^10.0|^11.0", "nunomaduro/collision": "^8.0", "larastan/larastan": "^2.0|^3.0", "pestphp/pest": "^2.34|^3.0|^4.0", From 1a8a92caef591441bc43dac3f7e5abcf5bd4a733 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:45:03 +0000 Subject: [PATCH 07/25] ci: update workflows for PHP 8.4/8.5 and Laravel 12/13 - run-tests: drop PHP 8.3 and Laravel 11 from the matrix - phpstan: run against PHP 8.5 https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy --- .github/workflows/phpstan.yml | 2 +- .github/workflows/run-tests.yml | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 2c6185e..1361437 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -16,7 +16,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: '8.5' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9b9e60b..fff09a2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,12 +14,10 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: [8.5, 8.4, 8.3] - laravel: ['13.*', '12.*', '11.*'] + php: [8.5, 8.4] + laravel: ['13.*', '12.*'] stability: [prefer-lowest, prefer-stable] include: - - laravel: 11.* - testbench: 9.17.0 - laravel: 12.* testbench: 10.* - laravel: 13.* From 185fefb338e79f40aea85e30d60a545c74db3add Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 14:39:15 +0000 Subject: [PATCH 08/25] feat: replace abstract service provider with a plain published stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 105 ++++++++++-------- ...SubscribableApplicationServiceProvider.php | 66 ----------- stubs/SubscribableServiceProvider.stub | 66 ++++------- ...ubscribeApplicationServiceProviderTest.php | 41 ------- .../DummyApplicationServiceProvider.php | 62 +++-------- 5 files changed, 91 insertions(+), 249 deletions(-) delete mode 100644 src/SubscribableApplicationServiceProvider.php delete mode 100644 tests/SubscribeApplicationServiceProviderTest.php diff --git a/README.md b/README.md index c23111b..625831c 100755 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The unsubscribe route accepts `GET` (browser link) and `POST` (one-click from em composer require ylsideas/subscribable-notifications:^2.0 ``` -Publish the application service provider stub: +Publish the application service provider: ```bash php artisan vendor:publish --tag=subscriber-provider @@ -52,6 +52,41 @@ Or in `config/app.php` for older projects: ], ``` +The published provider is a plain `ServiceProvider` — open it and fill in the handler closures. There is no base class to extend or abstract methods to implement: + +```php +use Illuminate\Support\ServiceProvider; +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; + +class SubscribableServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Subscriber::routes(); + + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { + // Remove the notifiable's subscription to the given mailing list. + }); + + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) { + // Remove the notifiable's subscription to all mailing lists. + }); + + Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + return redirect('/'); + }); + + Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + return true; + }); + + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + return true; + }); + } +} +``` + ## Setup ### 1. Apply the trait to your notifiable model @@ -144,62 +179,34 @@ class WeeklyDigest extends Notification implements AppliesToMailingList ### Configuring the unsubscribe handlers -The published `App\Providers\SubscribableServiceProvider` has five methods to implement. Each returns a closure (or a `Class@method` string) that will be called at the appropriate point in the unsubscribe flow. +The five `Subscriber::on*` calls in the published provider are the only configuration needed. Each accepts a closure or a `Class@method` string that will be resolved from the service container: ```php -use YlsIdeas\SubscribableNotifications\SubscribableApplicationServiceProvider; +Subscriber::onUnsubscribeFromAllMailingLists(\App\Handlers\UnsubscribeHandler::class . '@handleAll'); +``` -class SubscribableServiceProvider extends SubscribableApplicationServiceProvider -{ - // Called when a user unsubscribes from a specific mailing list - public function onUnsubscribeFromMailingList() - { - return function ($notifiable, string $mailingList) { - $notifiable->subscriptions()->where('list', $mailingList)->delete(); - }; - } +A realistic implementation might look like: - // Called when a user unsubscribes from all emails - public function onUnsubscribeFromAllMailingLists() - { - return function ($notifiable) { - $notifiable->update(['unsubscribed_at' => now()]); - }; - } - - // Called after unsubscribing to determine the browser response (GET requests only) - public function onCompletion() - { - return function ($notifiable, ?string $mailingList) { - return redirect()->route('unsubscribe.confirmed'); - }; - } +```php +Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { + $notifiable->subscriptions()->where('list', $mailingList)->delete(); +}); - // Return true if the notifiable is subscribed to a specific list - public function onCheckSubscriptionStatusOfMailingList() - { - return function ($notifiable, string $mailingList) { - return $notifiable->subscriptions()->where('list', $mailingList)->exists(); - }; - } +Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) { + $notifiable->update(['unsubscribed_at' => now()]); +}); - // Return true if the notifiable has not globally unsubscribed - public function onCheckSubscriptionStatusOfAllMailingLists() - { - return function ($notifiable) { - return $notifiable->unsubscribed_at === null; - }; - } -} -``` +Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + return redirect()->route('unsubscribe.confirmed'); +}); -You may also return a `Class@method` string and the class will be resolved from the service container: +Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + return $notifiable->subscriptions()->where('list', $mailingList)->exists(); +}); -```php -public function onUnsubscribeFromAllMailingLists() -{ - return \App\Handlers\UnsubscribeHandler::class . '@handleAll'; -} +Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + return $notifiable->unsubscribed_at === null; +}); ``` ### Blocking sends for unsubscribed users diff --git a/src/SubscribableApplicationServiceProvider.php b/src/SubscribableApplicationServiceProvider.php deleted file mode 100644 index 9ae4108..0000000 --- a/src/SubscribableApplicationServiceProvider.php +++ /dev/null @@ -1,66 +0,0 @@ -loadRoutes === true) { - $this->loadRoutes(); - } - - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onUnsubscribeFromMailingList( - $this->onUnsubscribeFromMailingList() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onUnsubscribeFromAllMailingLists( - $this->onUnsubscribeFromAllMailingLists() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onCompletion( - $this->onCompletion() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onCheckSubscriptionStatusOfMailingList( - $this->onCheckSubscriptionStatusOfMailingList() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onCheckSubscriptionStatusOfAllMailingLists( - $this->onCheckSubscriptionStatusOfAllMailingLists() - ); - } - - public function loadRoutes() - { - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::routes(); - } - - /** - * @return callable|string - */ - abstract public function onUnsubscribeFromMailingList(); - - /** - * @return callable|string - */ - abstract public function onUnsubscribeFromAllMailingLists(); - - /** - * @return callable|string - */ - abstract public function onCompletion(); - - /** - * @return callable|string - */ - abstract public function onCheckSubscriptionStatusOfMailingList(); - - /** - * @return callable|string - */ - abstract public function onCheckSubscriptionStatusOfAllMailingLists(); -} diff --git a/stubs/SubscribableServiceProvider.stub b/stubs/SubscribableServiceProvider.stub index 71acc9a..8a695d2 100644 --- a/stubs/SubscribableServiceProvider.stub +++ b/stubs/SubscribableServiceProvider.stub @@ -2,60 +2,36 @@ namespace App\Providers; -use YlsIdeas\SubscribableNotifications\SubscribableApplicationServiceProvider; +use Illuminate\Support\ServiceProvider; +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -class SubscribableServiceProvider extends SubscribableApplicationServiceProvider +class SubscribableServiceProvider extends ServiceProvider { - protected $loadRoutes = true; - - /** - * @return callable|string - */ - public function onUnsubscribeFromMailingList() + public function boot(): void { - return function ($user, $mailingList) { + Subscriber::routes(); - }; - } + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { + // Remove the notifiable's subscription to the given mailing list. + }); - /** - * @return callable|string - */ - public function onUnsubscribeFromAllMailingLists() - { - return function ($user) { - - }; - } + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) { + // Remove the notifiable's subscription to all mailing lists. + }); - /** - * @return callable|string - */ - public function onCompletion() - { - return function ($user, $mailingList) { - return response() - ->redirectTo('/'); - }; - } + Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + // Return the response shown to the user after unsubscribing (GET requests only). + return redirect('/'); + }); - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfMailingList() - { - return function ($user, $mailingList) { + Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + // Return true if the notifiable is subscribed to this mailing list. return true; - }; - } + }); - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfAllMailingLists() - { - return function ($user) { + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + // Return true if the notifiable has not globally unsubscribed. return true; - }; + }); } } diff --git a/tests/SubscribeApplicationServiceProviderTest.php b/tests/SubscribeApplicationServiceProviderTest.php deleted file mode 100644 index e3a1159..0000000 --- a/tests/SubscribeApplicationServiceProviderTest.php +++ /dev/null @@ -1,41 +0,0 @@ -app); - - $provider->shouldLoadRoutes(true); - - $provider->boot(); - } - - public function test_it_can_be_configured_to_not_loads_routes() - { - Subscriber::shouldReceive('routes')->never(); - Subscriber::shouldReceive('userModel'); - Subscriber::shouldReceive('onUnsubscribeFromMailingList'); - Subscriber::shouldReceive('onUnsubscribeFromAllMailingLists'); - Subscriber::shouldReceive('onCompletion'); - Subscriber::shouldReceive('onCheckSubscriptionStatusOfMailingList'); - Subscriber::shouldReceive('onCheckSubscriptionStatusOfAllMailingLists'); - $provider = new DummyApplicationServiceProvider($this->app); - - $provider->shouldLoadRoutes(false); - - $provider->boot(); - } -} diff --git a/tests/Support/DummyApplicationServiceProvider.php b/tests/Support/DummyApplicationServiceProvider.php index 08c2d14..b46de9f 100644 --- a/tests/Support/DummyApplicationServiceProvider.php +++ b/tests/Support/DummyApplicationServiceProvider.php @@ -2,63 +2,29 @@ namespace YlsIdeas\SubscribableNotifications\Tests\Support; -use YlsIdeas\SubscribableNotifications\SubscribableApplicationServiceProvider; +use Illuminate\Support\ServiceProvider; +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -class DummyApplicationServiceProvider extends SubscribableApplicationServiceProvider +class DummyApplicationServiceProvider extends ServiceProvider { - protected $loadRoutes = true; - - public function shouldLoadRoutes($shouldLoad = false) + public function boot(): void { - $this->loadRoutes = $shouldLoad; - } + Subscriber::routes(); - /** - * @return \Closure - */ - public function onUnsubscribeFromMailingList() - { - return function () { - }; - } + Subscriber::onUnsubscribeFromMailingList(function () {}); - /** - * @return \Closure - */ - public function onUnsubscribeFromAllMailingLists() - { - return function () { - }; - } + Subscriber::onUnsubscribeFromAllMailingLists(function () {}); - /** - * @return \Closure - */ - public function onCompletion() - { - return function () { - return response() - ->redirectTo('/'); - }; - } + Subscriber::onCompletion(function () { + return redirect('/'); + }); - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfMailingList() - { - return function () { + Subscriber::onCheckSubscriptionStatusOfMailingList(function () { return true; - }; - } + }); - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfAllMailingLists() - { - return function () { + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function () { return true; - }; + }); } } From d9b145accc8a093a52ff6d1ba0de363ca28274db Mon Sep 17 00:00:00 2001 From: peterfox <1716506+peterfox@users.noreply.github.com> Date: Tue, 26 May 2026 14:39:32 +0000 Subject: [PATCH 09/25] Fix styling --- tests/Support/DummyApplicationServiceProvider.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Support/DummyApplicationServiceProvider.php b/tests/Support/DummyApplicationServiceProvider.php index b46de9f..14139c1 100644 --- a/tests/Support/DummyApplicationServiceProvider.php +++ b/tests/Support/DummyApplicationServiceProvider.php @@ -11,9 +11,11 @@ public function boot(): void { Subscriber::routes(); - Subscriber::onUnsubscribeFromMailingList(function () {}); + Subscriber::onUnsubscribeFromMailingList(function () { + }); - Subscriber::onUnsubscribeFromAllMailingLists(function () {}); + Subscriber::onUnsubscribeFromAllMailingLists(function () { + }); Subscriber::onCompletion(function () { return redirect('/'); From 55bc083a00ee3fc34710e145b3c7c67146441b4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 18:34:02 +0000 Subject: [PATCH 10/25] feat: align email templates with current Laravel framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - html.blade.php: switch → match for button colour, @component('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 / 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 --- resources/views/html.blade.php | 26 ++++++-------- resources/views/mail/html/message.blade.php | 38 ++++++++++----------- resources/views/mail/text/message.blade.php | 34 +++++++++--------- resources/views/text.blade.php | 22 +++++------- 4 files changed, 56 insertions(+), 64 deletions(-) diff --git a/resources/views/html.blade.php b/resources/views/html.blade.php index aa5bc45..b4c85bb 100644 --- a/resources/views/html.blade.php +++ b/resources/views/html.blade.php @@ -19,18 +19,14 @@ {{-- Action Button --}} @isset($actionText) $level, + default => 'primary', + }; ?> -@component('mail::button', ['url' => $actionUrl, 'color' => $color]) + {{ $actionText }} -@endcomponent + @endisset {{-- Outro Lines --}} @@ -43,20 +39,20 @@ @if (! empty($salutation)) {{ $salutation }} @else -@lang('Regards'),
{{ config('app.name') }} +@lang('Regards,')
+{{ config('app.name') }} @endif {{-- Subcopy --}} @isset($actionText) @slot('subcopy') @lang( - "If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\n". - 'into your web browser: [:actionURL](:actionURL).', + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". + 'into your web browser:', [ 'actionText' => $actionText, - 'actionURL' => $actionUrl, ] -) +) [{{ $displayableActionUrl }}]({{ $actionUrl }}) @endslot @endisset diff --git a/resources/views/mail/html/message.blade.php b/resources/views/mail/html/message.blade.php index bbf14a4..242c389 100644 --- a/resources/views/mail/html/message.blade.php +++ b/resources/views/mail/html/message.blade.php @@ -1,31 +1,31 @@ -@component('mail::layout') + {{-- Header --}} - @slot('header') - @component('mail::header', ['url' => config('app.url')]) + + {{ config('app.name') }} - @endcomponent - @endslot + + {{-- Body --}} - {{ $slot }} + {!! $slot !!} {{-- Subcopy --}} @isset($subcopy) - @slot('subcopy') - @component('mail::subcopy') - {{ $subcopy }} - @endcomponent - @endslot + + + {!! $subcopy !!} + + @endisset {{-- Footer --}} - @slot('footer') - @component('mail::footer') - © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + + + © {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} @isset($unsubscribe) -
- {{ $unsubscribe }} +
+ {!! $unsubscribe !!} @endisset - @endcomponent - @endslot -@endcomponent +
+
+
diff --git a/resources/views/mail/text/message.blade.php b/resources/views/mail/text/message.blade.php index 3100bc0..b4c4720 100644 --- a/resources/views/mail/text/message.blade.php +++ b/resources/views/mail/text/message.blade.php @@ -1,30 +1,30 @@ -@component('mail::layout') + {{-- Header --}} - @slot('header') - @component('mail::header', ['url' => config('app.url')]) + + {{ config('app.name') }} - @endcomponent - @endslot + + {{-- Body --}} {{ $slot }} {{-- Subcopy --}} @isset($subcopy) - @slot('subcopy') - @component('mail::subcopy') + + {{ $subcopy }} - @endcomponent - @endslot + + @endisset {{-- Footer --}} - @slot('footer') - @component('mail::footer') - © {{ date('Y') }} {{ config('app.name') }}. @lang("All rights reserved.\n") -@isset($unsubscribe) + + + © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + @isset($unsubscribe) {{ $unsubscribe }} -@endisset - @endcomponent - @endslot -@endcomponent + @endisset + + + diff --git a/resources/views/text.blade.php b/resources/views/text.blade.php index c92e8cd..833fb3e 100644 --- a/resources/views/text.blade.php +++ b/resources/views/text.blade.php @@ -19,14 +19,10 @@ {{-- Action Button --}} @isset($actionText) $level, + default => 'primary', + }; ?> @component('mail::button', ['url' => $actionUrl, 'color' => $color]) {{ $actionText }} @@ -43,20 +39,20 @@ @if (! empty($salutation)) {{ $salutation }} @else -@lang('Regards'),
{{ config('app.name') }} +@lang('Regards,') +{{ config('app.name') }} @endif {{-- Subcopy --}} @isset($actionText) @slot('subcopy') @lang( - "If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\n". - 'into your web browser: [:actionURL](:actionURL).', + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". + 'into your web browser:', [ 'actionText' => $actionText, - 'actionURL' => $actionUrl, ] -) +) {{ $displayableActionUrl }} @endslot @endisset From adb628f1a0b4f4704bca1d184667a1413a283665 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 19:01:26 +0000 Subject: [PATCH 11/25] refactor: inline unsubscribe section, remove custom layout wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html.blade.php and text.blade.php now use 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 --- resources/views/html.blade.php | 35 +++++++++------------ resources/views/mail/html/message.blade.php | 31 ------------------ resources/views/mail/text/message.blade.php | 30 ------------------ resources/views/text.blade.php | 32 +++++++------------ 4 files changed, 27 insertions(+), 101 deletions(-) delete mode 100644 resources/views/mail/html/message.blade.php delete mode 100644 resources/views/mail/text/message.blade.php diff --git a/resources/views/html.blade.php b/resources/views/html.blade.php index b4c85bb..71c33e7 100644 --- a/resources/views/html.blade.php +++ b/resources/views/html.blade.php @@ -1,4 +1,4 @@ -@component('subscriber::mail.html.message') + {{-- Greeting --}} @if (! empty($greeting)) # {{ $greeting }} @@ -43,9 +43,20 @@ {{ config('app.name') }} @endif +{{-- Unsubscribe --}} +@if(($unsubscribeLink ?? false) || ($unsubscribeLinkForAll ?? false)) +--- +@if($unsubscribeLink ?? false) +@lang('If you no longer want to receive this type of email in the future use this [link](:link).', ['link' => $unsubscribeLink]) +@endif +@if($unsubscribeLinkForAll ?? false) +@lang('To no longer receive any future emails use this [link](:link).', ['link' => $unsubscribeLinkForAll]) +@endif +@endif + {{-- Subcopy --}} @isset($actionText) -@slot('subcopy') + @lang( "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". 'into your web browser:', @@ -53,22 +64,6 @@ 'actionText' => $actionText, ] ) [{{ $displayableActionUrl }}]({{ $actionUrl }}) -@endslot + @endisset - -@slot('unsubscribe') -@if($unsubscribeLink ?? false) -@lang( - 'If you no longer want to receive this type of email in the future use this [link](:link).', - ['link' => $unsubscribeLink] -) -@endif -@if($unsubscribeLinkForAll ?? false) -@lang( - 'To no longer receive any future emails use this [link](:link).', - ['link' => $unsubscribeLinkForAll] -) -@endif -@endslot - -@endcomponent + diff --git a/resources/views/mail/html/message.blade.php b/resources/views/mail/html/message.blade.php deleted file mode 100644 index 242c389..0000000 --- a/resources/views/mail/html/message.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - - {{-- Header --}} - - - {{ config('app.name') }} - - - - {{-- Body --}} - {!! $slot !!} - - {{-- Subcopy --}} - @isset($subcopy) - - - {!! $subcopy !!} - - - @endisset - - {{-- Footer --}} - - - © {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} - @isset($unsubscribe) -
- {!! $unsubscribe !!} - @endisset -
-
-
diff --git a/resources/views/mail/text/message.blade.php b/resources/views/mail/text/message.blade.php deleted file mode 100644 index b4c4720..0000000 --- a/resources/views/mail/text/message.blade.php +++ /dev/null @@ -1,30 +0,0 @@ - - {{-- Header --}} - - - {{ config('app.name') }} - - - - {{-- Body --}} - {{ $slot }} - - {{-- Subcopy --}} - @isset($subcopy) - - - {{ $subcopy }} - - - @endisset - - {{-- Footer --}} - - - © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') - @isset($unsubscribe) -{{ $unsubscribe }} - @endisset - - - diff --git a/resources/views/text.blade.php b/resources/views/text.blade.php index 833fb3e..7374ec2 100644 --- a/resources/views/text.blade.php +++ b/resources/views/text.blade.php @@ -1,4 +1,4 @@ -@component('subscriber::mail.text.message') + {{-- Greeting --}} @if (! empty($greeting)) # {{ $greeting }} @@ -43,9 +43,17 @@ {{ config('app.name') }} @endif +{{-- Unsubscribe --}} +@if($unsubscribeLink ?? false) +@lang("If you no longer want to receive this type of email in the future go to :link.\n", ['link' => $unsubscribeLink]) +@endif +@if($unsubscribeLinkForAll ?? false) +@lang("To no longer receive any future emails go to :link.\n", ['link' => $unsubscribeLinkForAll]) +@endif + {{-- Subcopy --}} @isset($actionText) -@slot('subcopy') + @lang( "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". 'into your web browser:', @@ -53,22 +61,6 @@ 'actionText' => $actionText, ] ) {{ $displayableActionUrl }} -@endslot + @endisset - -@slot('unsubscribe') -@if($unsubscribeLink ?? false) -@lang( - "If you no longer want to receive this type of email in the future go to :link.\n", - ['link' => $unsubscribeLink] -) -@endif -@if($unsubscribeLinkForAll ?? false) -@lang( - "To no longer receive any future emails go to :link.\n", - ['link' => $unsubscribeLinkForAll] -) -@endif -@endslot - -@endcomponent + From 931baae027bed938a32847de1e39faf8e2d02839 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 19:23:46 +0000 Subject: [PATCH 12/25] refactor: replace SubscriberMailChannel with SubscribableMailMessage and NotificationSending listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 / sections that silently prevented test execution). https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy --- phpunit.xml.dist | 12 +- src/Channels/SubscriberMailChannel.php | 128 ------ src/Messages/SubscribableMailMessage.php | 35 ++ src/SubscribableServiceProvider.php | 29 +- tests/Channels/SubscriberMailChannelTest.php | 377 ------------------ .../Messages/SubscribableMailMessageTest.php | 232 +++++++++++ tests/Support/DummyNotification.php | 22 +- .../DummyNotificationWithEnumMailingList.php | 6 +- .../DummyNotificationWithMailingList.php | 23 +- .../Support/DummyNotificationWithQueuing.php | 36 +- 10 files changed, 302 insertions(+), 598 deletions(-) delete mode 100644 src/Channels/SubscriberMailChannel.php create mode 100644 src/Messages/SubscribableMailMessage.php delete mode 100644 tests/Channels/SubscriberMailChannelTest.php create mode 100644 tests/Messages/SubscribableMailMessageTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a6f6a12..d30009a 100755 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,10 @@ - - - - - - - - + tests - - - diff --git a/src/Channels/SubscriberMailChannel.php b/src/Channels/SubscriberMailChannel.php deleted file mode 100644 index 5d9f2de..0000000 --- a/src/Channels/SubscriberMailChannel.php +++ /dev/null @@ -1,128 +0,0 @@ -checkMailSubscriptionStatus() && - ! $notifiable->mailSubscriptionStatus($notification)) { - return null; - } - - if (method_exists($notification, 'toMail')) { - $message = $notification->toMail($notifiable); - } else { - throw new \RuntimeException('Notification does not support sending mail'); - } - - // Inject unsubscribe links for rendering in the view - if ($notifiable instanceof CanUnsubscribe && $message instanceof MailMessage) { - if ($notification instanceof AppliesToMailingList) { - $message->viewData['unsubscribeLink'] = $notifiable->unsubscribeLink( - $this->mailingListValue($notification) - ); - } - $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); - } - - if (! $notifiable->routeNotificationFor('mail', $notification) && - ! $message instanceof Mailable) { - return null; - } - - if ($message instanceof Mailable) { - $message->send($this->mailer); - - return null; - } - - return $this->mailer->mailer($message->mailer ?? null)->send( - $this->buildView($message), - array_merge($message->data(), $this->additionalMessageData($notification)), - $this->messageBuilder($notifiable, $notification, $message) - ); - } - - /** - * Build the mail message. - * - * @param \Illuminate\Mail\Message $mailMessage - * @param mixed $notifiable - * @param \Illuminate\Notifications\Notification $notification - * @param \Illuminate\Notifications\Messages\MailMessage $message - * - * @return void - */ - protected function buildMessage($mailMessage, $notifiable, $notification, $message) - { - parent::buildMessage($mailMessage, $notifiable, $notification, $message); - - if ($notifiable instanceof CanUnsubscribe) { - $mailMessage->getHeaders()->addTextHeader( - 'List-Unsubscribe', - sprintf('<%s>', $notifiable->unsubscribeLink( - $notification instanceof AppliesToMailingList - ? $this->mailingListValue($notification) - : null - )) - ); - $mailMessage->getHeaders()->addTextHeader( - 'List-Unsubscribe-Post', - 'List-Unsubscribe=One-Click' - ); - } - } - - private function mailingListValue(AppliesToMailingList $notification): string - { - $list = $notification->usesMailingList(); - - return $list instanceof \BackedEnum ? $list->value : $list; - } - - /** - * Build the notification's view. - * - * @param \Illuminate\Notifications\Messages\MailMessage $message - * @return string|array - */ - protected function buildView($message) - { - if ($message->view) { - return $message->view; - } - - return [ - 'html' => $this->markdown->render('subscriber::html', $message->data()), - 'text' => $this->markdown->renderText('subscriber::text', $message->data()), - ]; - } -} diff --git a/src/Messages/SubscribableMailMessage.php b/src/Messages/SubscribableMailMessage.php new file mode 100644 index 0000000..1fb1e45 --- /dev/null +++ b/src/Messages/SubscribableMailMessage.php @@ -0,0 +1,35 @@ +usesMailingList() + : null; + $listValue = $list instanceof \BackedEnum ? $list->value : $list; + + if ($listValue !== null) { + $message->viewData['unsubscribeLink'] = $notifiable->unsubscribeLink($listValue); + } + $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); + + $unsubscribeUrl = $notifiable->unsubscribeLink($listValue); + + return $message->withSymfonyMessage(function (Email $email) use ($unsubscribeUrl) { + $email->getHeaders()->addTextHeader('List-Unsubscribe', sprintf('<%s>', $unsubscribeUrl)); + $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + }); + } +} diff --git a/src/SubscribableServiceProvider.php b/src/SubscribableServiceProvider.php index cacae53..76b0e9c 100755 --- a/src/SubscribableServiceProvider.php +++ b/src/SubscribableServiceProvider.php @@ -3,16 +3,15 @@ namespace YlsIdeas\SubscribableNotifications; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Notifications\Channels\MailChannel; +use Illuminate\Notifications\Events\NotificationSending; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use YlsIdeas\SubscribableNotifications\Channels\SubscriberMailChannel; +use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; +use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; class SubscribableServiceProvider extends ServiceProvider { - /** - * Bootstrap the application services. - */ - public function boot() + public function boot(): void { $this->loadViewsFrom(__DIR__.'/../resources/views', 'subscriber'); @@ -25,14 +24,22 @@ public function boot() __DIR__.'/../stubs/SubscribableServiceProvider.stub' => app_path('Providers/SubscribableServiceProvider.php'), ], 'subscriber-provider'); } + + Event::listen(NotificationSending::class, function (NotificationSending $event) { + if ($event->channel !== 'mail') { + return; + } + if ($event->notifiable instanceof CheckSubscriptionStatusBeforeSendingNotifications && + $event->notification instanceof CheckNotifiableSubscriptionStatus && + $event->notification->checkMailSubscriptionStatus() && + ! $event->notifiable->mailSubscriptionStatus($event->notification)) { + return false; + } + }); } - /** - * Register the application services. - */ - public function register() + public function register(): void { - $this->app->bind(MailChannel::class, SubscriberMailChannel::class); $this->app->singleton(Subscriber::class, function (Application $app) { /** @phpstan-ignore-next-line */ return new Subscriber($app); diff --git a/tests/Channels/SubscriberMailChannelTest.php b/tests/Channels/SubscriberMailChannelTest.php deleted file mode 100644 index 8b28116..0000000 --- a/tests/Channels/SubscriberMailChannelTest.php +++ /dev/null @@ -1,377 +0,0 @@ -notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayHasKey('unsubscribeLink', $event->data); - $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertEquals( - 'https://testing.local/unsubscribe/testing-list', - $event->data['unsubscribeLink'] - ); - $this->assertEquals( - 'https://testing.local/unsubscribe', - $event->data['unsubscribeLinkForAll'] - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - $this->assertEquals( - '', - $this->getHeaderContent($event->message, 'List-Unsubscribe') - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe-Post') - ); - $this->assertEquals( - 'List-Unsubscribe=One-Click', - $this->getHeaderContent($event->message, 'List-Unsubscribe-Post') - ); - - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'If you no longer want to receive this type of email in the future use this', - $content - ); - - $this->assertStringContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe/testing-list', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe', - $content - ); - - return true; - }); - } - - public function test_it_sends_mail_notifications_with_mailing_links_via_queues() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotificationWithQueuing(); - $notifiable = new DummyNotifiableWithSubscriptions(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertEquals( - 'https://testing.local/unsubscribe', - $event->data['unsubscribeLinkForAll'] - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - - $this->assertEquals( - '', - $this->getHeaderContent($event->message, 'List-Unsubscribe') - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe-Post') - ); - $this->assertEquals( - 'List-Unsubscribe=One-Click', - $this->getHeaderContent($event->message, 'List-Unsubscribe-Post') - ); - - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe', - $content - ); - - return true; - }); - } - - public function test_it_sends_mail_notifications_with_an_unsubscribe_link_for_all_emails() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $notifiable = new DummyNotifiableWithSubscriptions(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertEquals( - 'https://testing.local/unsubscribe', - $event->data['unsubscribeLinkForAll'] - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - $this->assertEquals( - '', - $this->getHeaderContent($event->message, 'List-Unsubscribe') - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe-Post') - ); - $this->assertEquals( - 'List-Unsubscribe=One-Click', - $this->getHeaderContent($event->message, 'List-Unsubscribe-Post') - ); - - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe', - $content - ); - - return true; - }); - } - - public function test_it_sends_mail_notifications_normally_otherwise() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $notifiable = new DummyNotifiable(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayNotHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertFalse( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - - $content = $this->getContent($event->message); - - $this->assertStringNotContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringNotContainsString( - 'https://testing.local/unsubscribe', - $content, - ); - - return true; - }); - } - - public function test_it_handles_mailables_as_per_inherited_behavior() - { - View::addNamespace('testing', __DIR__.'/../views'); - - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $expectedNotification->useMailable = true; - $notifiable = new DummyNotifiable(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'This is a dummy', - $content - ); - - return true; - }); - } - - public function test_it_checks_if_a_notifiable_is_subscribed_to_receive_the_notification() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $notification = new DummyNotificationWithMailingList(); - $notifiable = new DummyNotifiableWithSubscriptions(); - - $notification->shouldCheck = true; - $notifiable->isSubscribed = false; - - $notifiable->notify($notification); - - Event::assertNotDispatched(MessageSending::class); - } - - public function test_it_does_not_send_mail_if_there_is_no_email_to_route_to() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $notification = new DummyNotification(); - $notifiable = new DummyNotifiable(); - $notifiable->email = null; - - $notifiable->notify($notification); - - Event::assertNotDispatched(MessageSending::class); - } - - public function test_it_uses_views_set_on_the_mail_message_from_the_notification() - { - View::addNamespace('testing', __DIR__.'/../views'); - - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $expectedNotification->useView = 'testing::example'; - $notifiable = new DummyNotifiable(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'This is a dummy', - $content - ); - - return true; - }); - } - - public function test_it_resolves_enum_mailing_list_to_its_string_value() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $notifiable = new DummyNotifiableWithSubscriptions(); - $notifiable->notify(new DummyNotificationWithEnumMailingList()); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayHasKey('unsubscribeLink', $event->data); - $this->assertEquals( - 'https://testing.local/unsubscribe/newsletter', - $event->data['unsubscribeLink'] - ); - $this->assertEquals( - '', - $this->getHeaderContent($event->message, 'List-Unsubscribe') - ); - - return true; - }); - } - - /** - * @param \Swift_Message|Email $message - * @return string - */ - protected function getContent($message): string - { - if (get_class($message) === 'Swift_Message') { - return $message->getBody(); - } elseif (get_class($message) === 'Symfony\Component\Mime\Email') { - return $message->getHtmlBody(); - } - - throw new \Exception(sprintf('Could not establish Message class %s', get_class($message))); - } - - /** - * @param \Swift_Message|Email $message - * @return string - */ - protected function getHeaderContent($message, $header): string - { - if (get_class($message) === 'Swift_Message') { - return $message->getHeaders()->get($header, 0)->getValue(); - } elseif (get_class($message) === 'Symfony\Component\Mime\Email') { - return $message->getHeaders()->getHeaderBody($header); - } - - throw new \Exception(sprintf('Could not establish Message class %s', get_class($message))); - } -} diff --git a/tests/Messages/SubscribableMailMessageTest.php b/tests/Messages/SubscribableMailMessageTest.php new file mode 100644 index 0000000..0fa3657 --- /dev/null +++ b/tests/Messages/SubscribableMailMessageTest.php @@ -0,0 +1,232 @@ +assertEquals('subscriber::html', $message->markdown); + } + + public function test_via_sets_unsubscribe_link_for_all_emails() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotification()); + + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + $this->assertEquals('https://testing.local/unsubscribe', $message->viewData['unsubscribeLinkForAll']); + $this->assertArrayNotHasKey('unsubscribeLink', $message->viewData); + } + + public function test_via_sets_mailing_list_specific_unsubscribe_link() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotificationWithMailingList()); + + $this->assertArrayHasKey('unsubscribeLink', $message->viewData); + $this->assertEquals('https://testing.local/unsubscribe/testing-list', $message->viewData['unsubscribeLink']); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } + + public function test_via_resolves_enum_mailing_list_to_its_string_value() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotificationWithEnumMailingList()); + + $this->assertEquals('https://testing.local/unsubscribe/newsletter', $message->viewData['unsubscribeLink']); + } + + public function test_via_registers_a_symfony_message_callback() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotification()); + + $this->assertCount(1, $message->callbacks); + } + + public function test_it_sends_with_rfc8058_headers_for_mailing_list_notifications() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotificationWithMailingList()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLink', $event->data); + $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); + $this->assertEquals('https://testing.local/unsubscribe/testing-list', $event->data['unsubscribeLink']); + $this->assertEquals('https://testing.local/unsubscribe', $event->data['unsubscribeLinkForAll']); + $this->assertTrue($event->message->getHeaders()->has('List-Unsubscribe')); + $this->assertEquals( + '', + $this->headerBody($event->message, 'List-Unsubscribe') + ); + $this->assertTrue($event->message->getHeaders()->has('List-Unsubscribe-Post')); + $this->assertEquals('List-Unsubscribe=One-Click', $this->headerBody($event->message, 'List-Unsubscribe-Post')); + $content = $event->message->getHtmlBody(); + $this->assertStringContainsString('If you no longer want to receive this type of email in the future use this', $content); + $this->assertStringContainsString('To no longer receive any future emails', $content); + $this->assertStringContainsString('https://testing.local/unsubscribe/testing-list', $content); + $this->assertStringContainsString('https://testing.local/unsubscribe', $content); + + return true; + }); + } + + public function test_it_sends_with_rfc8058_headers_for_queued_notifications() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotificationWithQueuing()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); + $this->assertEquals('https://testing.local/unsubscribe', $event->data['unsubscribeLinkForAll']); + $this->assertTrue($event->message->getHeaders()->has('List-Unsubscribe')); + $this->assertEquals('', $this->headerBody($event->message, 'List-Unsubscribe')); + $this->assertEquals('List-Unsubscribe=One-Click', $this->headerBody($event->message, 'List-Unsubscribe-Post')); + $this->assertStringContainsString('To no longer receive any future emails', $event->message->getHtmlBody()); + + return true; + }); + } + + public function test_it_sends_with_unsubscribe_for_all_link_when_no_mailing_list() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotification()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); + $this->assertEquals('https://testing.local/unsubscribe', $event->data['unsubscribeLinkForAll']); + $this->assertEquals('', $this->headerBody($event->message, 'List-Unsubscribe')); + $this->assertStringContainsString('To no longer receive any future emails', $event->message->getHtmlBody()); + + return true; + }); + } + + public function test_it_sends_without_unsubscribe_links_for_non_subscribable_notifiables() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiable())->notify(new DummyNotification()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayNotHasKey('unsubscribeLinkForAll', $event->data); + $this->assertFalse($event->message->getHeaders()->has('List-Unsubscribe')); + $this->assertStringNotContainsString('To no longer receive any future emails', $event->message->getHtmlBody() ?? ''); + + return true; + }); + } + + public function test_it_sends_with_rfc8058_headers_for_enum_mailing_list() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotificationWithEnumMailingList()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLink', $event->data); + $this->assertEquals('https://testing.local/unsubscribe/newsletter', $event->data['unsubscribeLink']); + $this->assertEquals( + '', + $this->headerBody($event->message, 'List-Unsubscribe') + ); + + return true; + }); + } + + public function test_it_cancels_send_when_notifiable_is_not_subscribed() + { + Event::fake([MessageSending::class, MessageSent::class]); + + $notification = new DummyNotificationWithMailingList(); + $notification->shouldCheck = true; + $notifiable = new DummyNotifiableWithSubscriptions(); + $notifiable->isSubscribed = false; + + $notifiable->notify($notification); + + Event::assertNotDispatched(MessageSending::class); + } + + public function test_it_does_not_send_when_there_is_no_email_address() + { + Event::fake([MessageSending::class, MessageSent::class]); + + $notifiable = new DummyNotifiable(); + $notifiable->email = null; + + $notifiable->notify(new DummyNotification()); + + Event::assertNotDispatched(MessageSending::class); + } + + public function test_it_handles_mailables() + { + View::addNamespace('testing', __DIR__.'/../views'); + Event::fake([MessageSending::class, MessageSent::class]); + + $notification = new DummyNotification(); + $notification->useMailable = true; + + (new DummyNotifiable())->notify($notification); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertStringContainsString('This is a dummy', $event->message->getHtmlBody() ?? ''); + + return true; + }); + } + + public function test_it_uses_a_custom_view_when_set_on_the_message() + { + View::addNamespace('testing', __DIR__.'/../views'); + Event::fake([MessageSending::class, MessageSent::class]); + + $notification = new DummyNotification(); + $notification->useView = 'testing::example'; + + (new DummyNotifiable())->notify($notification); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertStringContainsString('This is a dummy', $event->message->getHtmlBody() ?? ''); + + return true; + }); + } + + protected function headerBody(Email $message, string $header): string + { + return $message->getHeaders()->getHeaderBody($header); + } +} diff --git a/tests/Support/DummyNotification.php b/tests/Support/DummyNotification.php index 81758f8..9f5fe38 100644 --- a/tests/Support/DummyNotification.php +++ b/tests/Support/DummyNotification.php @@ -4,6 +4,8 @@ use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; class DummyNotification extends Notification { @@ -11,25 +13,11 @@ class DummyNotification extends Notification public $useMailable = false; - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * - * @return array - */ public function via($notifiable) { return ['mail']; } - /** - * Get the mail representation of the notification. - * - * @param mixed $notifiable - * - * @return \Illuminate\Notifications\Messages\MailMessage - */ public function toMail($notifiable) { if ($this->useView !== null) { @@ -44,7 +32,11 @@ public function toMail($notifiable) return new DummyMailable(); } - return (new MailMessage()) + $message = $notifiable instanceof CanUnsubscribe + ? SubscribableMailMessage::via($notifiable, $this) + : new MailMessage(); + + return $message ->line('The introduction to the notification.') ->action('Notification Action', url('/')) ->line('Thank you for using our application!'); diff --git a/tests/Support/DummyNotificationWithEnumMailingList.php b/tests/Support/DummyNotificationWithEnumMailingList.php index cb147bd..464b88f 100644 --- a/tests/Support/DummyNotificationWithEnumMailingList.php +++ b/tests/Support/DummyNotificationWithEnumMailingList.php @@ -2,9 +2,9 @@ namespace YlsIdeas\SubscribableNotifications\Tests\Support; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; use YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList; +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; class DummyNotificationWithEnumMailingList extends Notification implements AppliesToMailingList { @@ -13,9 +13,9 @@ public function via($notifiable): array return ['mail']; } - public function toMail($notifiable): MailMessage + public function toMail($notifiable): SubscribableMailMessage { - return (new MailMessage()) + return SubscribableMailMessage::via($notifiable, $this) ->line('The introduction to the notification.'); } diff --git a/tests/Support/DummyNotificationWithMailingList.php b/tests/Support/DummyNotificationWithMailingList.php index 9e27e93..e5780a1 100644 --- a/tests/Support/DummyNotificationWithMailingList.php +++ b/tests/Support/DummyNotificationWithMailingList.php @@ -2,45 +2,28 @@ namespace YlsIdeas\SubscribableNotifications\Tests\Support; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; use YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList; use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; class DummyNotificationWithMailingList extends Notification implements AppliesToMailingList, CheckNotifiableSubscriptionStatus { public $shouldCheck = false; - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * - * @return array - */ public function via($notifiable) { return ['mail']; } - /** - * Get the mail representation of the notification. - * - * @param mixed $notifiable - * - * @return \Illuminate\Notifications\Messages\MailMessage - */ - public function toMail($notifiable) + public function toMail($notifiable): SubscribableMailMessage { - return (new MailMessage()) + return SubscribableMailMessage::via($notifiable, $this) ->line('The introduction to the notification.') ->action('Notification Action', url('/')) ->line('Thank you for using our application!'); } - /** - * @return string - */ public function usesMailingList(): string { return 'testing-list'; diff --git a/tests/Support/DummyNotificationWithQueuing.php b/tests/Support/DummyNotificationWithQueuing.php index ab87120..5310914 100644 --- a/tests/Support/DummyNotificationWithQueuing.php +++ b/tests/Support/DummyNotificationWithQueuing.php @@ -4,51 +4,21 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; class DummyNotificationWithQueuing extends Notification implements ShouldQueue { use Queueable; - public $useView = null; - - public $useMailable = false; - - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * - * @return array - */ public function via($notifiable) { return ['mail']; } - /** - * Get the mail representation of the notification. - * - * @param mixed $notifiable - * - * @return \Illuminate\Notifications\Messages\MailMessage - */ - public function toMail($notifiable) + public function toMail($notifiable): SubscribableMailMessage { - if ($this->useView !== null) { - return (new MailMessage()) - ->view($this->useView) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - if ($this->useMailable === true) { - return new DummyMailable(); - } - - return (new MailMessage()) + return SubscribableMailMessage::via($notifiable, $this) ->line('The introduction to the notification.') ->action('Notification Action', url('/')) ->line('Thank you for using our application!'); From 6b1d12c26f759e4715553dc365b0a00e492896c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 09:51:09 +0000 Subject: [PATCH 13/25] refactor: extract via() logic into SubscribableNotification trait 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 --- src/Concerns/SubscribableNotification.php | 36 +++++++++++++++++++ src/Messages/SubscribableMailMessage.php | 28 ++------------- .../Messages/SubscribableMailMessageTest.php | 23 ++++++++++++ 3 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 src/Concerns/SubscribableNotification.php diff --git a/src/Concerns/SubscribableNotification.php b/src/Concerns/SubscribableNotification.php new file mode 100644 index 0000000..c98ee4b --- /dev/null +++ b/src/Concerns/SubscribableNotification.php @@ -0,0 +1,36 @@ +markdown = $template; + + $list = $notification instanceof AppliesToMailingList + ? $notification->usesMailingList() + : null; + $listValue = $list instanceof \BackedEnum ? $list->value : $list; + + if ($listValue !== null) { + $message->viewData['unsubscribeLink'] = $notifiable->unsubscribeLink($listValue); + } + $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); + + $unsubscribeUrl = $notifiable->unsubscribeLink($listValue); + + return $message->withSymfonyMessage(function (Email $email) use ($unsubscribeUrl) { + $email->getHeaders()->addTextHeader('List-Unsubscribe', sprintf('<%s>', $unsubscribeUrl)); + $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + }); + } +} diff --git a/src/Messages/SubscribableMailMessage.php b/src/Messages/SubscribableMailMessage.php index 1fb1e45..d4e380d 100644 --- a/src/Messages/SubscribableMailMessage.php +++ b/src/Messages/SubscribableMailMessage.php @@ -3,33 +3,9 @@ namespace YlsIdeas\SubscribableNotifications\Messages; use Illuminate\Notifications\Messages\MailMessage; -use Symfony\Component\Mime\Email; -use YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList; -use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\Concerns\SubscribableNotification; class SubscribableMailMessage extends MailMessage { - public $markdown = 'subscriber::html'; - - public static function via(CanUnsubscribe $notifiable, object $notification): static - { - $message = new static(); - - $list = $notification instanceof AppliesToMailingList - ? $notification->usesMailingList() - : null; - $listValue = $list instanceof \BackedEnum ? $list->value : $list; - - if ($listValue !== null) { - $message->viewData['unsubscribeLink'] = $notifiable->unsubscribeLink($listValue); - } - $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); - - $unsubscribeUrl = $notifiable->unsubscribeLink($listValue); - - return $message->withSymfonyMessage(function (Email $email) use ($unsubscribeUrl) { - $email->getHeaders()->addTextHeader('List-Unsubscribe', sprintf('<%s>', $unsubscribeUrl)); - $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); - }); - } + use SubscribableNotification; } diff --git a/tests/Messages/SubscribableMailMessageTest.php b/tests/Messages/SubscribableMailMessageTest.php index 0fa3657..ac3b9cc 100644 --- a/tests/Messages/SubscribableMailMessageTest.php +++ b/tests/Messages/SubscribableMailMessageTest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\View; use Orchestra\Testbench\TestCase; use Symfony\Component\Mime\Email; +use Illuminate\Notifications\Messages\MailMessage; +use YlsIdeas\SubscribableNotifications\Concerns\SubscribableNotification; use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; use YlsIdeas\SubscribableNotifications\SubscribableServiceProvider; use YlsIdeas\SubscribableNotifications\Tests\Support\DummyNotifiable; @@ -62,6 +64,27 @@ public function test_via_resolves_enum_mailing_list_to_its_string_value() $this->assertEquals('https://testing.local/unsubscribe/newsletter', $message->viewData['unsubscribeLink']); } + public function test_via_accepts_a_custom_template() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotification(), 'my-package::custom'); + + $this->assertEquals('my-package::custom', $message->markdown); + } + + public function test_trait_can_be_applied_to_custom_mail_message_class() + { + $customClass = new class extends MailMessage { + use SubscribableNotification; + }; + + $message = $customClass::via(new DummyNotifiableWithSubscriptions(), new DummyNotification()); + + $this->assertInstanceOf($customClass::class, $message); + $this->assertEquals('subscriber::html', $message->markdown); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } + public function test_via_registers_a_symfony_message_callback() { $notifiable = new DummyNotifiableWithSubscriptions(); From 4e0dc8bad97f23c072ef951e40ef111236db1ae8 Mon Sep 17 00:00:00 2001 From: peterfox <1716506+peterfox@users.noreply.github.com> Date: Sun, 31 May 2026 09:51:29 +0000 Subject: [PATCH 14/25] Fix styling --- tests/Messages/SubscribableMailMessageTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Messages/SubscribableMailMessageTest.php b/tests/Messages/SubscribableMailMessageTest.php index ac3b9cc..df665af 100644 --- a/tests/Messages/SubscribableMailMessageTest.php +++ b/tests/Messages/SubscribableMailMessageTest.php @@ -4,11 +4,11 @@ use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Events\MessageSent; +use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\View; use Orchestra\Testbench\TestCase; use Symfony\Component\Mime\Email; -use Illuminate\Notifications\Messages\MailMessage; use YlsIdeas\SubscribableNotifications\Concerns\SubscribableNotification; use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; use YlsIdeas\SubscribableNotifications\SubscribableServiceProvider; @@ -74,7 +74,7 @@ public function test_via_accepts_a_custom_template() public function test_trait_can_be_applied_to_custom_mail_message_class() { - $customClass = new class extends MailMessage { + $customClass = new class () extends MailMessage { use SubscribableNotification; }; From 50f5f5d1dbef6f9db8e72f45b11ddce6a65794de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 10:17:42 +0000 Subject: [PATCH 15/25] =?UTF-8?q?feat:=20add=20legacy=20route=20support=20?= =?UTF-8?q?for=20v1=20=E2=86=92=20v2=20production=20upgrades,=20add=20UPGR?= =?UTF-8?q?ADE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- UPGRADE.md | 248 ++++++++++++++++++ .../LegacyUnsubscribeController.php | 25 ++ src/Facades/Subscriber.php | 1 + src/Subscriber.php | 34 ++- .../LegacyUnsubscribeControllerTest.php | 98 +++++++ 5 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 UPGRADE.md create mode 100644 src/Controllers/LegacyUnsubscribeController.php create mode 100644 tests/Controllers/LegacyUnsubscribeControllerTest.php diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..2252025 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,248 @@ +# Upgrade Guide + +## v1 → v2 + +### Overview of breaking changes + +| Area | v1 | v2 | +|---|---|---| +| PHP | 8.1+ | **8.4+** | +| Laravel | 9 / 10 / 11 | **12 / 13** | +| Service provider | Extend abstract `SubscribableApplicationServiceProvider` | Publish a plain stub and fill it in | +| Model binding | Single model class configured via `Subscriber::userModel()` | Polymorphic — any model works | +| Unsubscribe URL shape | `unsubscribe/{id}/{list?}` | `unsubscribe/{type}/{id}/{list?}` | +| Notification opt-in | Automatic for all `mail` channel notifications | Explicit — use `SubscribableMailMessage::via()` in `toMail()` | + +--- + +### Step 1 — Update composer.json + +```bash +composer require ylsideas/subscribable-notifications:^2.0 +``` + +--- + +### Step 2 — Replace SubscribableApplicationServiceProvider + +v1 required a class that extended the package's abstract `SubscribableApplicationServiceProvider` with five abstract methods. v2 replaces this with a plain stub you publish and configure. + +**Remove** your existing implementation (typically `App\Providers\SubscribableServiceProvider`): + +```php +// v1 — delete this +class SubscribableServiceProvider extends SubscribableApplicationServiceProvider +{ + protected $model = User::class; + + public function onUnsubscribeFromMailingList($user, $mailingList): void { ... } + public function onUnsubscribeFromAllMailingLists($user): void { ... } + public function onCompletion($user, ?string $mailingList): RedirectResponse { ... } + public function onCheckSubscriptionStatusForMailingLists($user, $mailingList): bool { ... } + public function onCheckSubscriptionStatusForAllMailingLists($user): bool { ... } +} +``` + +**Publish** the new stub: + +```bash +php artisan vendor:publish --tag=subscriber-provider +``` + +This creates `App\Providers\SubscribableServiceProvider`. Fill in the five callbacks — the logic you had in the abstract methods moves directly into `boot()`: + +```php +public function boot(): void +{ + Subscriber::routes(); + + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList): void { + // e.g. $notifiable->subscriptions()->where('list', $mailingList)->delete(); + }); + + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable): void { + // e.g. $notifiable->subscriptions()->delete(); + }); + + Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + return redirect('/'); + }); + + Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + return true; // e.g. $notifiable->isSubscribedTo($mailingList) + }); + + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + return true; // e.g. ! $notifiable->hasUnsubscribedFromAll() + }); +} +``` + +Register the provider in `bootstrap/providers.php` if it isn't there already. + +--- + +### Step 3 — Update your subscriber model + +Remove any `Subscriber::userModel(User::class)` calls — this method no longer exists. The model is now resolved polymorphically from the URL. + +Your model must implement `CanUnsubscribe` and use the `MailSubscriber` trait. If it already does, no change is needed here; the trait now generates polymorphic URLs automatically. + +```php +use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\MailSubscriber; + +class User extends Authenticatable implements CanUnsubscribe +{ + use MailSubscriber; +} +``` + +**Add a morph map (strongly recommended).** Without one, the full class name (`App\Models\User`) appears in every unsubscribe URL. A morph map gives you a short, stable key instead: + +```php +// AppServiceProvider::boot() +use Illuminate\Database\Eloquent\Relations\Relation; + +Relation::morphMap([ + 'user' => \App\Models\User::class, +]); +``` + +With this in place, URLs contain `user` instead of the full class name, and refactoring or renaming your model won't invalidate existing links. + +--- + +### Step 4 — Update your notifications + +v1 automatically injected unsubscribe links into every `mail` channel notification by replacing the built-in `MailChannel`. v2 requires explicit opt-in — return `SubscribableMailMessage::via($notifiable, $this)` instead of a plain `MailMessage`. + +```php +// v1 +use Illuminate\Notifications\Messages\MailMessage; + +public function toMail(object $notifiable): MailMessage +{ + return (new MailMessage()) + ->subject('Your weekly digest') + ->line('Here is your digest...'); +} +``` + +```php +// v2 +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; + +public function toMail(object $notifiable): SubscribableMailMessage +{ + return SubscribableMailMessage::via($notifiable, $this) + ->subject('Your weekly digest') + ->line('Here is your digest...'); +} +``` + +`via()` sets the unsubscribe view data and registers the RFC 8058 `List-Unsubscribe` and `List-Unsubscribe-Post` headers via a `withSymfonyMessage()` callback. It also defaults the markdown template to `subscriber::html`; pass a third argument to override: + +```php +SubscribableMailMessage::via($notifiable, $this, 'my-theme::email') +``` + +If you want to apply the same behaviour to a custom `MailMessage` subclass, use the `SubscribableNotification` trait directly instead of extending `SubscribableMailMessage`: + +```php +use Illuminate\Notifications\Messages\MailMessage; +use YlsIdeas\SubscribableNotifications\Concerns\SubscribableNotification; + +class MyMailMessage extends MailMessage +{ + use SubscribableNotification; +} +``` + +--- + +### Step 5 — Handle the route URL change (critical for live systems) + +This is the most important production concern. Every unsubscribe link already delivered to a user's inbox points at the v1 URL shape: + +``` +# v1 +https://example.com/unsubscribe/123/newsletter?signature=... + +# v2 +https://example.com/unsubscribe/user/123/newsletter?signature=... +``` + +Those v1 links will stop working the moment you deploy v2 if nothing is done. + +#### Option A — Register the legacy compatibility route (recommended) + +Call `Subscriber::legacyRoutes()` alongside `Subscriber::routes()` in your service provider: + +```php +public function boot(): void +{ + Subscriber::routes(); + Subscriber::legacyRoutes(App\Models\User::class); // default model for old links + + // ... handlers +} +``` + +This registers a second route at the old URL shape (`unsubscribe/{id}/{list?}`) that forwards requests to the same controller, automatically resolving the model from the class you provide. The v1 signed URL signatures are over the URL path, which is unchanged, so they validate correctly. + +Keep `legacyRoutes()` active for as long as you consider old emails to still be in circulation. A safe window is 6–12 months, but you can remove it earlier once you are confident v1-style links have expired. + +#### Option B — Accept that old links break + +If your use case is transactional (receipts, one-time alerts) rather than recurring newsletters, old links may not matter. Remove the legacy route call and move on. + +--- + +### Step 6 — Optional: migrate mailing list identifiers to enums + +v1 mailing lists were plain strings. v2 supports PHP 8.1 backed enums via the `AppliesToMailingList` contract: + +```php +// Before +public function usesMailingList(): string +{ + return 'newsletter'; +} + +// After +enum MailingList: string +{ + case Newsletter = 'newsletter'; +} + +public function usesMailingList(): string|\BackedEnum +{ + return MailingList::Newsletter; +} +``` + +The enum's raw value (`'newsletter'`) is used in the URL, so existing links continue to work. + +--- + +### Step 7 — Optional: use Subscriber::fake() in tests + +v2 adds a test fake so you can assert unsubscribe behaviour without side effects: + +```php +$fake = Subscriber::fake(); + +// ... trigger unsubscribe ... + +$fake->assertUnsubscribedFromMailingList($user, 'newsletter'); +$fake->assertUnsubscribedFromAll($user); +$fake->assertNothingUnsubscribed(); +``` + +Control subscription status in tests: + +```php +$fake->alwaysSubscribed(); // all subscription checks return true +$fake->alwaysUnsubscribed(); // all subscription checks return false +``` diff --git a/src/Controllers/LegacyUnsubscribeController.php b/src/Controllers/LegacyUnsubscribeController.php new file mode 100644 index 0000000..f0551f3 --- /dev/null +++ b/src/Controllers/LegacyUnsubscribeController.php @@ -0,0 +1,25 @@ +middleware('signed'); + } + + public function __invoke(Request $request, $subscriberId, ?string $mailingList = null) + { + return app(UnsubscribeController::class)( + $request, + $this->subscriber->legacySubscriberType, + $subscriberId, + $mailingList + ); + } +} diff --git a/src/Facades/Subscriber.php b/src/Facades/Subscriber.php index 18e80e5..400d315 100644 --- a/src/Facades/Subscriber.php +++ b/src/Facades/Subscriber.php @@ -12,6 +12,7 @@ * @see \YlsIdeas\SubscribableNotifications\Subscriber * * @method static void routes() + * @method static void legacyRoutes(string $defaultModel) * @method static string routeName() * @method static void onCompletion(callable|string $handler) * @method static void onUnsubscribeFromMailingList(callable|string $handler) diff --git a/src/Subscriber.php b/src/Subscriber.php index d0db975..539cfa3 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -3,23 +3,18 @@ namespace YlsIdeas\SubscribableNotifications; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Response; use Illuminate\Support\Str; class Subscriber { - /** - * @var string - */ public $uri = 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'; - /** - * @var string - */ public $hander = '\YlsIdeas\SubscribableNotifications\Controllers\UnsubscribeController'; - /** - * @var string - */ public $routeName = 'unsubscribe'; + + /** Morph type stored when legacyRoutes() is called — used by LegacyUnsubscribeController. */ + public ?string $legacySubscriberType = null; /** * @var callable */ @@ -51,15 +46,26 @@ public function __construct(Application $app) $this->app = $app; } - public function routes($router = null) + public function routes($router = null): void { $router = $router ?? $this->app->make('router'); + $router->match(['GET', 'POST'], $this->uri, $this->hander) + ->name($this->routeName) + ->where('subscriberType', '[^\d/][^/]*'); + } + + public function legacyRoutes(string $defaultModel, $router = null): void + { + $router = $router ?? $this->app->make('router'); + + $morphMap = Relation::morphMap(); + $this->legacySubscriberType = array_search($defaultModel, $morphMap, true) ?: $defaultModel; + $router->match( ['GET', 'POST'], - $this->uri, - $this->hander - ) - ->name($this->routeName); + 'unsubscribe/{subscriberId}/{mailingList?}', + '\YlsIdeas\SubscribableNotifications\Controllers\LegacyUnsubscribeController' + )->name($this->routeName . '.legacy'); } /** diff --git a/tests/Controllers/LegacyUnsubscribeControllerTest.php b/tests/Controllers/LegacyUnsubscribeControllerTest.php new file mode 100644 index 0000000..e6a7153 --- /dev/null +++ b/tests/Controllers/LegacyUnsubscribeControllerTest.php @@ -0,0 +1,98 @@ +loadLaravelMigrations(); + } + + protected function getPackageProviders($app) + { + return [ + SubscribableServiceProvider::class, + DummyApplicationServiceProvider::class, + ]; + } + + protected function defineEnvironment($app): void + { + // Register the legacy route pointing at DummyUser (simulating a v1 User-only setup) + Subscriber::legacyRoutes(DummyUser::class); + } + + public function test_it_unsubscribes_via_a_legacy_url_without_subscriber_type() + { + $this->withoutExceptionHandling(); + + $called = false; + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) use (&$called, $user) { + $called = true; + $this->assertEquals($user->id, $notifiable->id); + }); + + $url = URL::signedRoute('unsubscribe.legacy', ['subscriberId' => $user->id]); + + $this->get($url)->assertStatus(302)->assertRedirect('/'); + + $this->assertTrue($called); + } + + public function test_it_unsubscribes_from_a_mailing_list_via_a_legacy_url() + { + $this->withoutExceptionHandling(); + + $called = false; + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, $list) use (&$called, $user) { + $called = true; + $this->assertEquals($user->id, $notifiable->id); + $this->assertEquals('newsletter', $list); + }); + + $url = URL::signedRoute('unsubscribe.legacy', ['subscriberId' => $user->id, 'mailingList' => 'newsletter']); + + $this->get($url)->assertStatus(302)->assertRedirect('/'); + + $this->assertTrue($called); + } + + public function test_it_returns_204_for_rfc8058_post_via_legacy_url() + { + $this->withoutExceptionHandling(); + + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + $url = URL::signedRoute('unsubscribe.legacy', ['subscriberId' => $user->id]); + + $this->post($url)->assertNoContent(); + } + + public function test_legacy_url_does_not_interfere_with_new_route() + { + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + // A v2-style URL (subscriberType is a non-numeric string) must hit the new route, + // not the legacy one + $newUrl = $user->unsubscribeLink(); + + $this->assertStringContainsString('/unsubscribe/', $newUrl); + $this->assertMatchesRegularExpression('#/unsubscribe/\D[^/]*/[^/]+#', $newUrl); + } +} From 5018a49d2db0c7861acb5fda650a5bc24f3b41c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 11:10:26 +0000 Subject: [PATCH 16/25] =?UTF-8?q?refactor:=20modernise=20package=20for=20P?= =?UTF-8?q?HP=208.4=20=E2=80=94=20final=20classes,=20readonly,=20typed=20p?= =?UTF-8?q?roperties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../LegacyUnsubscribeController.php | 4 +- src/Controllers/UnsubscribeController.php | 30 +--- src/Events/UserUnsubscribed.php | 22 +-- src/Events/UserUnsubscribing.php | 22 +-- src/Facades/Subscriber.php | 2 +- src/MailSubscriber.php | 8 -- src/SubscribableServiceProvider.php | 8 +- src/Subscriber.php | 135 +++++------------- src/Testing/FakeSubscriber.php | 56 ++++---- tests/MailSubscriberTest.php | 18 +-- 10 files changed, 89 insertions(+), 216 deletions(-) diff --git a/src/Controllers/LegacyUnsubscribeController.php b/src/Controllers/LegacyUnsubscribeController.php index f0551f3..b7fe08f 100644 --- a/src/Controllers/LegacyUnsubscribeController.php +++ b/src/Controllers/LegacyUnsubscribeController.php @@ -6,14 +6,14 @@ use Illuminate\Routing\Controller; use YlsIdeas\SubscribableNotifications\Subscriber; -class LegacyUnsubscribeController extends Controller +final class LegacyUnsubscribeController extends Controller { public function __construct(private readonly Subscriber $subscriber) { $this->middleware('signed'); } - public function __invoke(Request $request, $subscriberId, ?string $mailingList = null) + public function __invoke(Request $request, mixed $subscriberId, ?string $mailingList = null): mixed { return app(UnsubscribeController::class)( $request, diff --git a/src/Controllers/UnsubscribeController.php b/src/Controllers/UnsubscribeController.php index 9c835c4..753e145 100644 --- a/src/Controllers/UnsubscribeController.php +++ b/src/Controllers/UnsubscribeController.php @@ -4,43 +4,20 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Illuminate\Routing\Controller; use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribed; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribing; use YlsIdeas\SubscribableNotifications\Subscriber; -/** - * Class UnsubscribeController. - */ -class UnsubscribeController extends Controller +final class UnsubscribeController extends Controller { - /** - * @var Subscriber - */ - protected $subscriber; - - /** - * UnsubscribeController constructor. - * @param Subscriber $subscriber - */ - public function __construct(Subscriber $subscriber) + public function __construct(private readonly Subscriber $subscriber) { $this->middleware('signed'); - $this->subscriber = $subscriber; } - /** - * Handle the incoming request. - * - * @param Request $request - * @param string $subscriberType - * @param mixed $subscriberId - * @param string|null $mailingList - * @return Response - */ - public function __invoke(Request $request, string $subscriberType, $subscriberId, ?string $mailingList = null) + public function __invoke(Request $request, string $subscriberType, mixed $subscriberId, ?string $mailingList = null): mixed { $modelClass = Relation::getMorphedModel($subscriberType) ?? $subscriberType; @@ -68,7 +45,6 @@ public function __invoke(Request $request, string $subscriberType, $subscriberId event(new UserUnsubscribed($subscriber, $mailingList)); - // RFC 8058: one-click POST must not redirect; return 204 No Content if ($request->isMethod('post')) { return response('', 204); } diff --git a/src/Events/UserUnsubscribed.php b/src/Events/UserUnsubscribed.php index 2b20a09..2b50623 100644 --- a/src/Events/UserUnsubscribed.php +++ b/src/Events/UserUnsubscribed.php @@ -2,22 +2,10 @@ namespace YlsIdeas\SubscribableNotifications\Events; -use Illuminate\Foundation\Auth\User; - -class UserUnsubscribed +final class UserUnsubscribed { - /** - * @var User - */ - public $user; - /** - * @var string|null - */ - public $mailingList; - - public function __construct($user, ?string $mailingList = null) - { - $this->user = $user; - $this->mailingList = $mailingList; - } + public function __construct( + public readonly object $user, + public readonly ?string $mailingList = null, + ) {} } diff --git a/src/Events/UserUnsubscribing.php b/src/Events/UserUnsubscribing.php index 9c82ddf..b595748 100644 --- a/src/Events/UserUnsubscribing.php +++ b/src/Events/UserUnsubscribing.php @@ -2,22 +2,10 @@ namespace YlsIdeas\SubscribableNotifications\Events; -use Illuminate\Foundation\Auth\User; - -class UserUnsubscribing +final class UserUnsubscribing { - /** - * @var User - */ - public $user; - /** - * @var string|null - */ - public $mailingList; - - public function __construct($user, ?string $mailingList = null) - { - $this->user = $user; - $this->mailingList = $mailingList; - } + public function __construct( + public readonly object $user, + public readonly ?string $mailingList = null, + ) {} } diff --git a/src/Facades/Subscriber.php b/src/Facades/Subscriber.php index 400d315..3a539a9 100644 --- a/src/Facades/Subscriber.php +++ b/src/Facades/Subscriber.php @@ -26,7 +26,7 @@ */ class Subscriber extends Facade { - protected static function getFacadeAccessor() + protected static function getFacadeAccessor(): string { return \YlsIdeas\SubscribableNotifications\Subscriber::class; } diff --git a/src/MailSubscriber.php b/src/MailSubscriber.php index 902fc95..4decab5 100644 --- a/src/MailSubscriber.php +++ b/src/MailSubscriber.php @@ -9,10 +9,6 @@ trait MailSubscriber { - /** - * @param string|null $mailingList - * @return string - */ public function unsubscribeLink(?string $mailingList = null): string { return URL::signedRoute( @@ -25,10 +21,6 @@ public function unsubscribeLink(?string $mailingList = null): string ); } - /** - * @param Notification $notification - * @return bool - */ public function mailSubscriptionStatus(Notification $notification): bool { $list = $notification instanceof AppliesToMailingList diff --git a/src/SubscribableServiceProvider.php b/src/SubscribableServiceProvider.php index 76b0e9c..494922f 100755 --- a/src/SubscribableServiceProvider.php +++ b/src/SubscribableServiceProvider.php @@ -2,14 +2,13 @@ namespace YlsIdeas\SubscribableNotifications; -use Illuminate\Contracts\Foundation\Application; use Illuminate\Notifications\Events\NotificationSending; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; -class SubscribableServiceProvider extends ServiceProvider +final class SubscribableServiceProvider extends ServiceProvider { public function boot(): void { @@ -40,9 +39,6 @@ public function boot(): void public function register(): void { - $this->app->singleton(Subscriber::class, function (Application $app) { - /** @phpstan-ignore-next-line */ - return new Subscriber($app); - }); + $this->app->singleton(Subscriber::class, fn () => new Subscriber($this->app)); } } diff --git a/src/Subscriber.php b/src/Subscriber.php index 539cfa3..6c84231 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -4,57 +4,35 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Relations\Relation; -use Illuminate\Http\Response; use Illuminate\Support\Str; -class Subscriber +final class Subscriber { - public $uri = 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'; - public $hander = '\YlsIdeas\SubscribableNotifications\Controllers\UnsubscribeController'; - public $routeName = 'unsubscribe'; + public string $uri = 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'; + public string $handler = '\YlsIdeas\SubscribableNotifications\Controllers\UnsubscribeController'; + public string $routeName = 'unsubscribe'; /** Morph type stored when legacyRoutes() is called — used by LegacyUnsubscribeController. */ public ?string $legacySubscriberType = null; - /** - * @var callable - */ - protected $onUnsubscribeFromMailingList; - /** - * @var callable - */ - protected $onUnsubscribeFromAllMailingLists; - /** - * @var callable - */ - protected $onCompletion; - /** - * @var callable - */ - protected $onCheckSubscriptionStatusForMailingLists; - /** - * @var callable - */ - protected $onCheckSubscriptionStatusForAllMailingLists; - - /** - * @var Application - */ - protected $app; - - public function __construct(Application $app) - { - $this->app = $app; - } - public function routes($router = null): void + private ?\Closure $onUnsubscribeFromMailingList = null; + private ?\Closure $onUnsubscribeFromAllMailingLists = null; + private ?\Closure $onCompletion = null; + private ?\Closure $onCheckSubscriptionStatusForMailingLists = null; + private ?\Closure $onCheckSubscriptionStatusForAllMailingLists = null; + + public function __construct(private readonly Application $app) + {} + + public function routes(mixed $router = null): void { $router = $router ?? $this->app->make('router'); - $router->match(['GET', 'POST'], $this->uri, $this->hander) + $router->match(['GET', 'POST'], $this->uri, $this->handler) ->name($this->routeName) ->where('subscriberType', '[^\d/][^/]*'); } - public function legacyRoutes(string $defaultModel, $router = null): void + public function legacyRoutes(string $defaultModel, mixed $router = null): void { $router = $router ?? $this->app->make('router'); @@ -68,110 +46,69 @@ public function legacyRoutes(string $defaultModel, $router = null): void )->name($this->routeName . '.legacy'); } - /** - * @return string - */ - public function routeName() + public function routeName(): string { return $this->routeName; } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onUnsubscribeFromMailingList($handler) + public function onUnsubscribeFromMailingList(string|callable $handler): void { $this->onUnsubscribeFromMailingList = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onUnsubscribeFromAllMailingLists($handler) + public function onUnsubscribeFromAllMailingLists(string|callable $handler): void { $this->onUnsubscribeFromAllMailingLists = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onCompletion($handler) + public function onCompletion(string|callable $handler): void { $this->onCompletion = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onCheckSubscriptionStatusOfAllMailingLists($handler) + public function onCheckSubscriptionStatusOfAllMailingLists(string|callable $handler): void { $this->onCheckSubscriptionStatusForAllMailingLists = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onCheckSubscriptionStatusOfMailingList($handler) + public function onCheckSubscriptionStatusOfMailingList(string|callable $handler): void { $this->onCheckSubscriptionStatusForMailingLists = $this->parseHandler($handler); } - /** - * @param mixed $user - * @param string $mailingList - */ - public function unsubscribeFromMailingList($user, string $mailingList) + public function unsubscribeFromMailingList(mixed $user, string $mailingList): void { - call_user_func($this->onUnsubscribeFromMailingList, $user, $mailingList); + ($this->onUnsubscribeFromMailingList)($user, $mailingList); } - /** - * @param mixed $user - */ - public function unsubscribeFromAllMailingLists($user) + public function unsubscribeFromAllMailingLists(mixed $user): void { - call_user_func($this->onUnsubscribeFromAllMailingLists, $user); + ($this->onUnsubscribeFromAllMailingLists)($user); } - /** - * @param mixed $user - * @param string|null $mailingList - * @return Response - */ - public function complete($user, ?string $mailingList = null) + public function complete(mixed $user, ?string $mailingList = null): mixed { - return call_user_func($this->onCompletion, $user, $mailingList); + return ($this->onCompletion)($user, $mailingList); } - /** - * @param mixed $user - * @param string|null $mailingList - * @return bool - */ - public function checkSubscriptionStatus($user, ?string $mailingList = null) + public function checkSubscriptionStatus(mixed $user, ?string $mailingList = null): bool { if ($mailingList !== null) { - return (bool) call_user_func($this->onCheckSubscriptionStatusForAllMailingLists, $user) && - (bool) call_user_func($this->onCheckSubscriptionStatusForMailingLists, $user, $mailingList); + return (bool) ($this->onCheckSubscriptionStatusForAllMailingLists)($user) + && (bool) ($this->onCheckSubscriptionStatusForMailingLists)($user, $mailingList); } - return (bool) call_user_func($this->onCheckSubscriptionStatusForAllMailingLists, $user); + return (bool) ($this->onCheckSubscriptionStatusForAllMailingLists)($user); } - protected function parseHandler(string|callable$handler): callable + private function parseHandler(string|callable $handler): \Closure { if (is_string($handler)) { - $parsed = Str::parseCallback($handler); - $parsed[0] = $this->app->make($parsed[0]); + [$class, $method] = Str::parseCallback($handler, '__invoke'); - return $parsed; + return \Closure::fromCallable([$this->app->make($class), $method]); } - return $handler; + return \Closure::fromCallable($handler); } } diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php index 0c3eaea..932cd0f 100644 --- a/src/Testing/FakeSubscriber.php +++ b/src/Testing/FakeSubscriber.php @@ -5,62 +5,53 @@ use Illuminate\Http\Response; use PHPUnit\Framework\Assert; -class FakeSubscriber +final class FakeSubscriber { public string $routeName = 'unsubscribe'; - protected array $unsubscribedFromMailingList = []; + private array $unsubscribedFromMailingList = []; + private array $unsubscribedFromAll = []; + private array $subscriptionStatusChecks = []; + private bool $subscriptionStatus = true; - protected array $unsubscribedFromAll = []; + public function routes(mixed $router = null): void {} - protected bool $subscriptionStatus = true; - - public function routes($router = null): void - { - } + public function legacyRoutes(string $defaultModel, mixed $router = null): void {} public function routeName(): string { return $this->routeName; } - public function onUnsubscribeFromMailingList($handler): void - { - } + public function onUnsubscribeFromMailingList(mixed $handler): void {} - public function onUnsubscribeFromAllMailingLists($handler): void - { - } + public function onUnsubscribeFromAllMailingLists(mixed $handler): void {} - public function onCompletion($handler): void - { - } + public function onCompletion(mixed $handler): void {} - public function onCheckSubscriptionStatusOfAllMailingLists($handler): void - { - } + public function onCheckSubscriptionStatusOfAllMailingLists(mixed $handler): void {} - public function onCheckSubscriptionStatusOfMailingList($handler): void - { - } + public function onCheckSubscriptionStatusOfMailingList(mixed $handler): void {} - public function unsubscribeFromMailingList($user, string $mailingList): void + public function unsubscribeFromMailingList(mixed $user, string $mailingList): void { $this->unsubscribedFromMailingList[] = ['user' => $user, 'list' => $mailingList]; } - public function unsubscribeFromAllMailingLists($user): void + public function unsubscribeFromAllMailingLists(mixed $user): void { $this->unsubscribedFromAll[] = $user; } - public function complete($user, ?string $mailingList = null): Response + public function complete(mixed $user, ?string $mailingList = null): Response { return new Response('', 200); } - public function checkSubscriptionStatus($user, ?string $mailingList = null): bool + public function checkSubscriptionStatus(mixed $user, ?string $mailingList = null): bool { + $this->subscriptionStatusChecks[] = ['user' => $user, 'mailingList' => $mailingList]; + return $this->subscriptionStatus; } @@ -95,6 +86,17 @@ public function assertUnsubscribedFromAll(mixed $user): void ); } + public function assertCheckedSubscriptionStatus(mixed $user, ?string $mailingList): void + { + Assert::assertTrue( + collect($this->subscriptionStatusChecks) + ->contains(fn ($item) => $item['user'] === $user && $item['mailingList'] === $mailingList), + $mailingList !== null + ? "Failed asserting that subscription status was checked for mailing list [{$mailingList}]." + : 'Failed asserting that subscription status was checked for all mailing lists.' + ); + } + public function assertNothingUnsubscribed(): void { Assert::assertEmpty( diff --git a/tests/MailSubscriberTest.php b/tests/MailSubscriberTest.php index 4238d21..5edc4dc 100644 --- a/tests/MailSubscriberTest.php +++ b/tests/MailSubscriberTest.php @@ -81,37 +81,31 @@ public function test_it_generates_a_signed_url_for_users_to_unsubscribe_from_a_m public function test_it_can_check_its_subscription_status_for_all_mailing_lists() { - /** @var DummyUser $user */ $user = DummyUser::make([ 'name' => 'test', 'email' => 'test@testing.local', 'password' => 'test', ]); - $notification = new DummyNotification(); + $fake = Subscriber::fake()->alwaysSubscribed(); - Subscriber::shouldReceive('checkSubscriptionStatus') - ->with($user, null) - ->andReturn(true); + $this->assertTrue($user->mailSubscriptionStatus(new DummyNotification())); - $this->assertTrue($user->mailSubscriptionStatus($notification)); + $fake->assertCheckedSubscriptionStatus($user, null); } public function test_it_can_check_its_subscription_status_for_one_mailing_list() { - /** @var DummyUser $user */ $user = DummyUser::make([ 'name' => 'test', 'email' => 'test@testing.local', 'password' => 'test', ]); - $notification = new DummyNotificationWithMailingList(); + $fake = Subscriber::fake()->alwaysSubscribed(); - Subscriber::shouldReceive('checkSubscriptionStatus') - ->with($user, 'testing-list') - ->andReturn(true); + $this->assertTrue($user->mailSubscriptionStatus(new DummyNotificationWithMailingList())); - $this->assertTrue($user->mailSubscriptionStatus($notification)); + $fake->assertCheckedSubscriptionStatus($user, 'testing-list'); } } From 5158d7f4121f4c01df2769db05a49707272ad0ff Mon Sep 17 00:00:00 2001 From: peterfox <1716506+peterfox@users.noreply.github.com> Date: Sun, 31 May 2026 11:10:45 +0000 Subject: [PATCH 17/25] Fix styling --- src/Events/UserUnsubscribed.php | 3 ++- src/Events/UserUnsubscribing.php | 3 ++- src/Subscriber.php | 3 ++- src/Testing/FakeSubscriber.php | 28 +++++++++++++++++++++------- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/Events/UserUnsubscribed.php b/src/Events/UserUnsubscribed.php index 2b50623..d1d428b 100644 --- a/src/Events/UserUnsubscribed.php +++ b/src/Events/UserUnsubscribed.php @@ -7,5 +7,6 @@ final class UserUnsubscribed public function __construct( public readonly object $user, public readonly ?string $mailingList = null, - ) {} + ) { + } } diff --git a/src/Events/UserUnsubscribing.php b/src/Events/UserUnsubscribing.php index b595748..1b7525a 100644 --- a/src/Events/UserUnsubscribing.php +++ b/src/Events/UserUnsubscribing.php @@ -7,5 +7,6 @@ final class UserUnsubscribing public function __construct( public readonly object $user, public readonly ?string $mailingList = null, - ) {} + ) { + } } diff --git a/src/Subscriber.php b/src/Subscriber.php index 6c84231..aba7815 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -22,7 +22,8 @@ final class Subscriber private ?\Closure $onCheckSubscriptionStatusForAllMailingLists = null; public function __construct(private readonly Application $app) - {} + { + } public function routes(mixed $router = null): void { diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php index 932cd0f..0630c9d 100644 --- a/src/Testing/FakeSubscriber.php +++ b/src/Testing/FakeSubscriber.php @@ -14,24 +14,38 @@ final class FakeSubscriber private array $subscriptionStatusChecks = []; private bool $subscriptionStatus = true; - public function routes(mixed $router = null): void {} + public function routes(mixed $router = null): void + { + } - public function legacyRoutes(string $defaultModel, mixed $router = null): void {} + public function legacyRoutes(string $defaultModel, mixed $router = null): void + { + } public function routeName(): string { return $this->routeName; } - public function onUnsubscribeFromMailingList(mixed $handler): void {} + public function onUnsubscribeFromMailingList(mixed $handler): void + { + } - public function onUnsubscribeFromAllMailingLists(mixed $handler): void {} + public function onUnsubscribeFromAllMailingLists(mixed $handler): void + { + } - public function onCompletion(mixed $handler): void {} + public function onCompletion(mixed $handler): void + { + } - public function onCheckSubscriptionStatusOfAllMailingLists(mixed $handler): void {} + public function onCheckSubscriptionStatusOfAllMailingLists(mixed $handler): void + { + } - public function onCheckSubscriptionStatusOfMailingList(mixed $handler): void {} + public function onCheckSubscriptionStatusOfMailingList(mixed $handler): void + { + } public function unsubscribeFromMailingList(mixed $user, string $mailingList): void { From cf9c66fec757a159e1c1b7890eca3ff16252cbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 11:34:45 +0000 Subject: [PATCH 18/25] Security hardening: encapsulate internals, throttle routes, validate 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 --- src/Concerns/SubscribableNotification.php | 6 +++--- src/Controllers/LegacyUnsubscribeController.php | 2 +- src/Controllers/UnsubscribeController.php | 4 ++++ src/Subscriber.php | 13 ++++++++++--- stubs/SubscribableServiceProvider.stub | 2 ++ .../LegacyUnsubscribeControllerTest.php | 2 +- tests/Controllers/UnsubscribeControllerTest.php | 16 ++++++++++++++-- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/Concerns/SubscribableNotification.php b/src/Concerns/SubscribableNotification.php index c98ee4b..05130df 100644 --- a/src/Concerns/SubscribableNotification.php +++ b/src/Concerns/SubscribableNotification.php @@ -21,13 +21,13 @@ public static function via( : null; $listValue = $list instanceof \BackedEnum ? $list->value : $list; + $unsubscribeUrl = $notifiable->unsubscribeLink($listValue); + if ($listValue !== null) { - $message->viewData['unsubscribeLink'] = $notifiable->unsubscribeLink($listValue); + $message->viewData['unsubscribeLink'] = $unsubscribeUrl; } $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); - $unsubscribeUrl = $notifiable->unsubscribeLink($listValue); - return $message->withSymfonyMessage(function (Email $email) use ($unsubscribeUrl) { $email->getHeaders()->addTextHeader('List-Unsubscribe', sprintf('<%s>', $unsubscribeUrl)); $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); diff --git a/src/Controllers/LegacyUnsubscribeController.php b/src/Controllers/LegacyUnsubscribeController.php index b7fe08f..2655b71 100644 --- a/src/Controllers/LegacyUnsubscribeController.php +++ b/src/Controllers/LegacyUnsubscribeController.php @@ -17,7 +17,7 @@ public function __invoke(Request $request, mixed $subscriberId, ?string $mailing { return app(UnsubscribeController::class)( $request, - $this->subscriber->legacySubscriberType, + $this->subscriber->getLegacySubscriberType(), $subscriberId, $mailingList ); diff --git a/src/Controllers/UnsubscribeController.php b/src/Controllers/UnsubscribeController.php index 753e145..bc40668 100644 --- a/src/Controllers/UnsubscribeController.php +++ b/src/Controllers/UnsubscribeController.php @@ -46,6 +46,10 @@ public function __invoke(Request $request, string $subscriberType, mixed $subscr event(new UserUnsubscribed($subscriber, $mailingList)); if ($request->isMethod('post')) { + if ($request->input('List-Unsubscribe') !== 'One-Click') { + abort(400, __('Invalid unsubscribe request')); + } + return response('', 204); } diff --git a/src/Subscriber.php b/src/Subscriber.php index aba7815..140eb1b 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -13,7 +13,12 @@ final class Subscriber public string $routeName = 'unsubscribe'; /** Morph type stored when legacyRoutes() is called — used by LegacyUnsubscribeController. */ - public ?string $legacySubscriberType = null; + private ?string $legacySubscriberType = null; + + public function getLegacySubscriberType(): ?string + { + return $this->legacySubscriberType; + } private ?\Closure $onUnsubscribeFromMailingList = null; private ?\Closure $onUnsubscribeFromAllMailingLists = null; @@ -30,7 +35,8 @@ public function routes(mixed $router = null): void $router = $router ?? $this->app->make('router'); $router->match(['GET', 'POST'], $this->uri, $this->handler) ->name($this->routeName) - ->where('subscriberType', '[^\d/][^/]*'); + ->where('subscriberType', '[^\d/][^/]*') + ->middleware('throttle:60,1'); } public function legacyRoutes(string $defaultModel, mixed $router = null): void @@ -44,7 +50,8 @@ public function legacyRoutes(string $defaultModel, mixed $router = null): void ['GET', 'POST'], 'unsubscribe/{subscriberId}/{mailingList?}', '\YlsIdeas\SubscribableNotifications\Controllers\LegacyUnsubscribeController' - )->name($this->routeName . '.legacy'); + )->name($this->routeName . '.legacy') + ->middleware('throttle:60,1'); } public function routeName(): string diff --git a/stubs/SubscribableServiceProvider.stub b/stubs/SubscribableServiceProvider.stub index 8a695d2..83ccba6 100644 --- a/stubs/SubscribableServiceProvider.stub +++ b/stubs/SubscribableServiceProvider.stub @@ -9,6 +9,8 @@ class SubscribableServiceProvider extends ServiceProvider { public function boot(): void { + // Note: register this route outside the 'web' middleware group to avoid CSRF token + // verification on the POST (RFC 8058 one-click) endpoint. Subscriber::routes(); Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { diff --git a/tests/Controllers/LegacyUnsubscribeControllerTest.php b/tests/Controllers/LegacyUnsubscribeControllerTest.php index e6a7153..222616a 100644 --- a/tests/Controllers/LegacyUnsubscribeControllerTest.php +++ b/tests/Controllers/LegacyUnsubscribeControllerTest.php @@ -81,7 +81,7 @@ public function test_it_returns_204_for_rfc8058_post_via_legacy_url() $url = URL::signedRoute('unsubscribe.legacy', ['subscriberId' => $user->id]); - $this->post($url)->assertNoContent(); + $this->post($url, ['List-Unsubscribe' => 'One-Click'])->assertNoContent(); } public function test_legacy_url_does_not_interfere_with_new_route() diff --git a/tests/Controllers/UnsubscribeControllerTest.php b/tests/Controllers/UnsubscribeControllerTest.php index 0690c58..7344851 100644 --- a/tests/Controllers/UnsubscribeControllerTest.php +++ b/tests/Controllers/UnsubscribeControllerTest.php @@ -182,7 +182,7 @@ public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe() $called = true; }); - $this->post($expectedUser->unsubscribeLink()) + $this->post($expectedUser->unsubscribeLink(), ['List-Unsubscribe' => 'One-Click']) ->assertNoContent(); $this->assertTrue($called); @@ -205,9 +205,21 @@ public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe_from_ $this->assertEquals('newsletter', $list); }); - $this->post($expectedUser->unsubscribeLink('newsletter')) + $this->post($expectedUser->unsubscribeLink('newsletter'), ['List-Unsubscribe' => 'One-Click']) ->assertNoContent(); $this->assertTrue($called); } + + public function test_it_rejects_post_without_rfc8058_body() + { + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + $this->post($expectedUser->unsubscribeLink()) + ->assertStatus(400); + } } From 7c9d39104691cd101d52498c5cc3671f4cdf75d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 11:38:34 +0000 Subject: [PATCH 19/25] Make throttle rate configurable via routes() and legacyRoutes() parameters 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 --- src/Facades/Subscriber.php | 4 ++-- src/Subscriber.php | 22 ++++++++++++++-------- src/Testing/FakeSubscriber.php | 4 ++-- stubs/SubscribableServiceProvider.stub | 3 +++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/Facades/Subscriber.php b/src/Facades/Subscriber.php index 3a539a9..c0ee21c 100644 --- a/src/Facades/Subscriber.php +++ b/src/Facades/Subscriber.php @@ -11,8 +11,8 @@ * * @see \YlsIdeas\SubscribableNotifications\Subscriber * - * @method static void routes() - * @method static void legacyRoutes(string $defaultModel) + * @method static void routes(mixed $router = null, string|false $throttle = '60,1') + * @method static void legacyRoutes(string $defaultModel, mixed $router = null, string|false $throttle = '60,1') * @method static string routeName() * @method static void onCompletion(callable|string $handler) * @method static void onUnsubscribeFromMailingList(callable|string $handler) diff --git a/src/Subscriber.php b/src/Subscriber.php index 140eb1b..e2b9042 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -30,28 +30,34 @@ public function __construct(private readonly Application $app) { } - public function routes(mixed $router = null): void + public function routes(mixed $router = null, string|false $throttle = '60,1'): void { $router = $router ?? $this->app->make('router'); - $router->match(['GET', 'POST'], $this->uri, $this->handler) + $route = $router->match(['GET', 'POST'], $this->uri, $this->handler) ->name($this->routeName) - ->where('subscriberType', '[^\d/][^/]*') - ->middleware('throttle:60,1'); + ->where('subscriberType', '[^\d/][^/]*'); + + if ($throttle !== false) { + $route->middleware("throttle:{$throttle}"); + } } - public function legacyRoutes(string $defaultModel, mixed $router = null): void + public function legacyRoutes(string $defaultModel, mixed $router = null, string|false $throttle = '60,1'): void { $router = $router ?? $this->app->make('router'); $morphMap = Relation::morphMap(); $this->legacySubscriberType = array_search($defaultModel, $morphMap, true) ?: $defaultModel; - $router->match( + $route = $router->match( ['GET', 'POST'], 'unsubscribe/{subscriberId}/{mailingList?}', '\YlsIdeas\SubscribableNotifications\Controllers\LegacyUnsubscribeController' - )->name($this->routeName . '.legacy') - ->middleware('throttle:60,1'); + )->name($this->routeName . '.legacy'); + + if ($throttle !== false) { + $route->middleware("throttle:{$throttle}"); + } } public function routeName(): string diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php index 0630c9d..64deb63 100644 --- a/src/Testing/FakeSubscriber.php +++ b/src/Testing/FakeSubscriber.php @@ -14,11 +14,11 @@ final class FakeSubscriber private array $subscriptionStatusChecks = []; private bool $subscriptionStatus = true; - public function routes(mixed $router = null): void + public function routes(mixed $router = null, string|false $throttle = '60,1'): void { } - public function legacyRoutes(string $defaultModel, mixed $router = null): void + public function legacyRoutes(string $defaultModel, mixed $router = null, string|false $throttle = '60,1'): void { } diff --git a/stubs/SubscribableServiceProvider.stub b/stubs/SubscribableServiceProvider.stub index 83ccba6..3a6555a 100644 --- a/stubs/SubscribableServiceProvider.stub +++ b/stubs/SubscribableServiceProvider.stub @@ -11,6 +11,9 @@ class SubscribableServiceProvider extends ServiceProvider { // Note: register this route outside the 'web' middleware group to avoid CSRF token // verification on the POST (RFC 8058 one-click) endpoint. + // The throttle defaults to '60,1' (60 requests per minute). Adjust or disable: + // Subscriber::routes(throttle: '10,1'); + // Subscriber::routes(throttle: false); Subscriber::routes(); Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { From 36fc888b45d45f0ff490c6f90b50aa774978aa0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 11:41:31 +0000 Subject: [PATCH 20/25] docs: document configurable throttle, CSRF warning, and legacy route - 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 --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index 625831c..df4417d 100755 --- a/README.md +++ b/README.md @@ -87,6 +87,53 @@ class SubscribableServiceProvider extends ServiceProvider } ``` +## Route configuration + +### Throttling + +The unsubscribe route is throttled to 60 requests per minute by default. Pass a custom rate or `false` to the `throttle` parameter to override this: + +```php +Subscriber::routes(); // default: 60 requests per minute +Subscriber::routes(throttle: '10,1'); // 10 requests per minute +Subscriber::routes(throttle: false); // disable throttling +``` + +The `throttle` parameter accepts the same `limit,decay` string format as Laravel's `throttle` middleware. + +### CSRF + +Register the unsubscribe route **outside** the `web` middleware group. RFC 8058 one-click POST requests from email clients do not include a CSRF token, and wrapping the route in `web` will cause all POST unsubscribes to fail with a 419. + +The safest approach is to call `Subscriber::routes()` at the top of your service provider's `boot` method, before `Route::middleware('web')->group(...)`: + +```php +public function boot(): void +{ + Subscriber::routes(); + + // ... rest of your route/handler registration +} +``` + +### Legacy route (v1 compatibility) + +If you are running a rolling upgrade from v1 and need old unsubscribe URLs (which do not include the subscriber type) to keep working, register the legacy route alongside the new one: + +```php +Subscriber::routes(); +Subscriber::legacyRoutes(\App\Models\User::class); +``` + +The legacy route matches `unsubscribe/{subscriberId}/{mailingList?}` and resolves the subscriber type to the given model (or its morph-map alias). All throttle options apply: + +```php +Subscriber::legacyRoutes(\App\Models\User::class, throttle: '10,1'); +Subscriber::legacyRoutes(\App\Models\User::class, throttle: false); +``` + +See [UPGRADE.md](UPGRADE.md) for the full migration guide. + ## Setup ### 1. Apply the trait to your notifiable model @@ -297,6 +344,7 @@ Available assertions: | `assertUnsubscribedFromMailingList($notifiable, $list)` | Assert the notifiable was unsubscribed from a specific list | | `assertUnsubscribedFromAll($notifiable)` | Assert the notifiable was globally unsubscribed | | `assertNothingUnsubscribed()` | Assert no unsubscribe actions occurred | +| `assertCheckedSubscriptionStatus($notifiable, $list)` | Assert subscription status was checked for the given list (`null` for global) | To control subscription status checks in feature tests: From 7201da7bfccd9910c4ff3e5566a0172c8d6741d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 19:10:01 +0000 Subject: [PATCH 21/25] Add publishable test stubs for common application scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 19 +++ src/SubscribableServiceProvider.php | 5 + stubs/tests/SubscribableNotificationTest.stub | 129 ++++++++++++++++++ stubs/tests/UnsubscribeRouteTest.stub | 87 ++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 stubs/tests/SubscribableNotificationTest.stub create mode 100644 stubs/tests/UnsubscribeRouteTest.stub diff --git a/README.md b/README.md index df4417d..80478c0 100755 --- a/README.md +++ b/README.md @@ -313,6 +313,25 @@ This creates `resources/views/vendor/subscriber/html.blade.php` and `text.blade. ## Testing +### Scaffolding your tests + +Publish ready-to-customise test stubs into your application's `tests/Feature/` directory: + +```bash +php artisan vendor:publish --tag=subscriber-tests +``` + +This creates two files: + +| File | What it covers | +|------|----------------| +| `tests/Feature/UnsubscribeRouteTest.php` | GET and POST unsubscribe routes, RFC 8058 body validation, tampered-URL rejection | +| `tests/Feature/SubscribableNotificationTest.php` | Subscription gating (subscribed sends, unsubscribed dropped), view data presence | + +Each file is annotated with `// TODO:` markers wherever you need to substitute your own model or notification class. The stubs use `Subscriber::fake()` so no real database handlers need to be configured. + +### Using the fake + Use `Subscriber::fake()` in your tests to swap in a fake implementation and make assertions without needing real handlers configured: ```php diff --git a/src/SubscribableServiceProvider.php b/src/SubscribableServiceProvider.php index 494922f..ef54f39 100755 --- a/src/SubscribableServiceProvider.php +++ b/src/SubscribableServiceProvider.php @@ -22,6 +22,11 @@ public function boot(): void $this->publishes([ __DIR__.'/../stubs/SubscribableServiceProvider.stub' => app_path('Providers/SubscribableServiceProvider.php'), ], 'subscriber-provider'); + + $this->publishes([ + __DIR__.'/../stubs/tests/UnsubscribeRouteTest.stub' => base_path('tests/Feature/UnsubscribeRouteTest.php'), + __DIR__.'/../stubs/tests/SubscribableNotificationTest.stub' => base_path('tests/Feature/SubscribableNotificationTest.php'), + ], 'subscriber-tests'); } Event::listen(NotificationSending::class, function (NotificationSending $event) { diff --git a/stubs/tests/SubscribableNotificationTest.stub b/stubs/tests/SubscribableNotificationTest.stub new file mode 100644 index 0000000..54f8df6 --- /dev/null +++ b/stubs/tests/SubscribableNotificationTest.stub @@ -0,0 +1,129 @@ +alwaysSubscribed(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with your notification class + $user->notify(new \App\Notifications\YourNotification()); + + // TODO: Replace with your notification class + Notification::assertSentTo($user, \App\Notifications\YourNotification::class); + } + + /** + * Verify that a globally unsubscribed user does not receive the notification. + * + * Requires the same model and notification setup as the test above. + */ + public function test_globally_unsubscribed_user_does_not_receive_notification(): void + { + Notification::fake(); + Subscriber::fake()->alwaysUnsubscribed(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with your notification class + $user->notify(new \App\Notifications\YourNotification()); + + // TODO: Replace with your notification class + Notification::assertNotSentTo($user, \App\Notifications\YourNotification::class); + } + + /** + * Verify that a user unsubscribed from a specific list does not receive + * notifications that target that list. + * + * Requires your notification to implement AppliesToMailingList in addition + * to CheckNotifiableSubscriptionStatus. + */ + public function test_user_unsubscribed_from_mailing_list_does_not_receive_notification(): void + { + Notification::fake(); + + // alwaysUnsubscribed() makes both the global and per-list checks return false. + // To test only per-list gating, configure a real handler in your service provider + // that returns false only for the specific list. + Subscriber::fake()->alwaysUnsubscribed(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a notification that implements AppliesToMailingList + $user->notify(new \App\Notifications\YourNotification()); + + // TODO: Replace with your notification class + Notification::assertNotSentTo($user, \App\Notifications\YourNotification::class); + } + + /** + * Verify the mail message includes the expected unsubscribe view data. + * + * Tests the output of your notification's toMail() method directly, without + * going through the notification channel pipeline. + */ + public function test_notification_email_includes_unsubscribe_links(): void + { + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with your notification class + $notification = new \App\Notifications\YourNotification(); + + /** @var SubscribableMailMessage $message */ + $message = $notification->toMail($user); + + $this->assertInstanceOf(SubscribableMailMessage::class, $message); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } + + /** + * Verify the mail message includes a per-list unsubscribe link when the + * notification implements AppliesToMailingList. + */ + public function test_mailing_list_notification_includes_list_specific_unsubscribe_link(): void + { + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a notification that implements AppliesToMailingList + $notification = new \App\Notifications\YourNotification(); + + /** @var SubscribableMailMessage $message */ + $message = $notification->toMail($user); + + $this->assertInstanceOf(SubscribableMailMessage::class, $message); + $this->assertArrayHasKey('unsubscribeLink', $message->viewData); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } +} diff --git a/stubs/tests/UnsubscribeRouteTest.stub b/stubs/tests/UnsubscribeRouteTest.stub new file mode 100644 index 0000000..50f76a4 --- /dev/null +++ b/stubs/tests/UnsubscribeRouteTest.stub @@ -0,0 +1,87 @@ +create(); + + $this->get($user->unsubscribeLink())->assertSuccessful(); + + $fake->assertUnsubscribedFromAll($user); + } + + public function test_user_can_unsubscribe_from_a_specific_mailing_list(): void + { + $fake = Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a real mailing list identifier used by your application + $this->get($user->unsubscribeLink('your-mailing-list'))->assertSuccessful(); + + $fake->assertUnsubscribedFromMailingList($user, 'your-mailing-list'); + } + + public function test_rfc8058_one_click_post_unsubscribes_user(): void + { + $fake = Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + $this->post($user->unsubscribeLink(), ['List-Unsubscribe' => 'One-Click']) + ->assertNoContent(); + + $fake->assertUnsubscribedFromAll($user); + } + + public function test_rfc8058_one_click_post_unsubscribes_user_from_mailing_list(): void + { + $fake = Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a real mailing list identifier used by your application + $this->post($user->unsubscribeLink('your-mailing-list'), ['List-Unsubscribe' => 'One-Click']) + ->assertNoContent(); + + $fake->assertUnsubscribedFromMailingList($user, 'your-mailing-list'); + } + + public function test_post_without_rfc8058_body_is_rejected(): void + { + Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + $this->post($user->unsubscribeLink())->assertBadRequest(); + } + + public function test_tampered_unsubscribe_url_is_rejected(): void + { + Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + $this->get($user->unsubscribeLink() . '&tampered=1')->assertForbidden(); + } +} From f901ca1ddecc09353b0a2a0b74f0a90697a67f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 19:54:01 +0000 Subject: [PATCH 22/25] Fix six QA issues: interface contract, null guards, fake compatibility, 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 --- README.md | 30 +++++++++++++++++ composer.json | 2 +- src/Contracts/SubscriberContract.php | 32 +++++++++++++++++++ .../LegacyUnsubscribeController.php | 4 +-- src/Controllers/UnsubscribeController.php | 4 +-- src/SubscribableServiceProvider.php | 2 ++ src/Subscriber.php | 20 +++++++++++- src/Testing/FakeSubscriber.php | 8 ++++- stubs/tests/SubscribableNotificationTest.stub | 27 +++++++++------- 9 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 src/Contracts/SubscriberContract.php diff --git a/README.md b/README.md index 80478c0..a2bdcbe 100755 --- a/README.md +++ b/README.md @@ -372,6 +372,36 @@ Subscriber::fake()->alwaysUnsubscribed(); // all checkSubscriptionStatus calls r Subscriber::fake()->alwaysSubscribed(); // all checkSubscriptionStatus calls return true (default) ``` +### Testing subscription gating (`Mail::fake()` vs `Notification::fake()`) + +When testing whether an unsubscribed user is silently dropped, use `Mail::fake()` — **not** `Notification::fake()`. The subscription gate runs inside a `NotificationSending` event listener; `Notification::fake()` bypasses that event entirely, so the gate never fires and every notification appears to be sent regardless of subscription status. + +`Mail::fake()` intercepts at the transport layer, leaving the full notification pipeline — including the `NotificationSending` event — running normally: + +```php +use Illuminate\Support\Facades\Mail; + +it('does not send mail to unsubscribed users', function () { + Mail::fake(); + Subscriber::fake()->alwaysUnsubscribed(); + + $user = User::factory()->create(); + $user->notify(new WeeklyDigest()); + + Mail::assertNothingSent(); +}); + +it('sends mail to subscribed users', function () { + Mail::fake(); + Subscriber::fake()->alwaysSubscribed(); + + $user = User::factory()->create(); + $user->notify(new WeeklyDigest()); + + Mail::assertSentCount(1); +}); +``` + ## Running the test suite ```bash diff --git a/composer.json b/composer.json index 27d51bd..6546955 100755 --- a/composer.json +++ b/composer.json @@ -59,7 +59,7 @@ "YlsIdeas\\SubscribableNotifications\\SubscribableServiceProvider" ], "aliases": { - "Subscriber": "YlsIdeas\\Facades\\Subscriber" + "Subscriber": "YlsIdeas\\SubscribableNotifications\\Facades\\Subscriber" } } }, diff --git a/src/Contracts/SubscriberContract.php b/src/Contracts/SubscriberContract.php new file mode 100644 index 0000000..a435543 --- /dev/null +++ b/src/Contracts/SubscriberContract.php @@ -0,0 +1,32 @@ +middleware('signed'); } diff --git a/src/Controllers/UnsubscribeController.php b/src/Controllers/UnsubscribeController.php index bc40668..245e65b 100644 --- a/src/Controllers/UnsubscribeController.php +++ b/src/Controllers/UnsubscribeController.php @@ -6,13 +6,13 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribed; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribing; -use YlsIdeas\SubscribableNotifications\Subscriber; final class UnsubscribeController extends Controller { - public function __construct(private readonly Subscriber $subscriber) + public function __construct(private readonly SubscriberContract $subscriber) { $this->middleware('signed'); } diff --git a/src/SubscribableServiceProvider.php b/src/SubscribableServiceProvider.php index ef54f39..8d9d7ab 100755 --- a/src/SubscribableServiceProvider.php +++ b/src/SubscribableServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Support\ServiceProvider; use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; final class SubscribableServiceProvider extends ServiceProvider { @@ -45,5 +46,6 @@ public function boot(): void public function register(): void { $this->app->singleton(Subscriber::class, fn () => new Subscriber($this->app)); + $this->app->alias(Subscriber::class, SubscriberContract::class); } } diff --git a/src/Subscriber.php b/src/Subscriber.php index e2b9042..662e446 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -5,8 +5,9 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Str; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; -final class Subscriber +final class Subscriber implements SubscriberContract { public string $uri = 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'; public string $handler = '\YlsIdeas\SubscribableNotifications\Controllers\UnsubscribeController'; @@ -92,22 +93,39 @@ public function onCheckSubscriptionStatusOfMailingList(string|callable $handler) public function unsubscribeFromMailingList(mixed $user, string $mailingList): void { + if ($this->onUnsubscribeFromMailingList === null) { + throw new \LogicException('No handler registered for mailing-list unsubscribes. Call Subscriber::onUnsubscribeFromMailingList() in your service provider.'); + } ($this->onUnsubscribeFromMailingList)($user, $mailingList); } public function unsubscribeFromAllMailingLists(mixed $user): void { + if ($this->onUnsubscribeFromAllMailingLists === null) { + throw new \LogicException('No handler registered for global unsubscribes. Call Subscriber::onUnsubscribeFromAllMailingLists() in your service provider.'); + } ($this->onUnsubscribeFromAllMailingLists)($user); } public function complete(mixed $user, ?string $mailingList = null): mixed { + if ($this->onCompletion === null) { + throw new \LogicException('No completion handler registered. Call Subscriber::onCompletion() in your service provider.'); + } return ($this->onCompletion)($user, $mailingList); } public function checkSubscriptionStatus(mixed $user, ?string $mailingList = null): bool { + if ($this->onCheckSubscriptionStatusForAllMailingLists === null) { + return true; + } + if ($mailingList !== null) { + if ($this->onCheckSubscriptionStatusForMailingLists === null) { + return (bool) ($this->onCheckSubscriptionStatusForAllMailingLists)($user); + } + return (bool) ($this->onCheckSubscriptionStatusForAllMailingLists)($user) && (bool) ($this->onCheckSubscriptionStatusForMailingLists)($user, $mailingList); } diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php index 64deb63..1e74694 100644 --- a/src/Testing/FakeSubscriber.php +++ b/src/Testing/FakeSubscriber.php @@ -4,8 +4,9 @@ use Illuminate\Http\Response; use PHPUnit\Framework\Assert; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; -final class FakeSubscriber +final class FakeSubscriber implements SubscriberContract { public string $routeName = 'unsubscribe'; @@ -22,6 +23,11 @@ public function legacyRoutes(string $defaultModel, mixed $router = null, string| { } + public function getLegacySubscriberType(): ?string + { + return null; + } + public function routeName(): string { return $this->routeName; diff --git a/stubs/tests/SubscribableNotificationTest.stub b/stubs/tests/SubscribableNotificationTest.stub index 54f8df6..1b240f6 100644 --- a/stubs/tests/SubscribableNotificationTest.stub +++ b/stubs/tests/SubscribableNotificationTest.stub @@ -3,7 +3,7 @@ namespace Tests\Feature; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Mail; use Tests\TestCase; use YlsIdeas\SubscribableNotifications\Facades\Subscriber; use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; @@ -24,10 +24,16 @@ class SubscribableNotificationTest extends TestCase * CheckSubscriptionStatusBeforeSendingNotifications. * - Your notification implements CheckNotifiableSubscriptionStatus * and returns true from checkMailSubscriptionStatus(). + * + * Note: use Mail::fake() rather than Notification::fake() here. + * Notification::fake() bypasses the NotificationSending event, which + * is where this package's subscription gate lives. Mail::fake() + * intercepts at the transport layer while still letting the full + * notification pipeline — and the gate — run normally. */ public function test_subscribed_user_receives_notification(): void { - Notification::fake(); + Mail::fake(); Subscriber::fake()->alwaysSubscribed(); // TODO: Replace with your notifiable model factory @@ -36,8 +42,7 @@ class SubscribableNotificationTest extends TestCase // TODO: Replace with your notification class $user->notify(new \App\Notifications\YourNotification()); - // TODO: Replace with your notification class - Notification::assertSentTo($user, \App\Notifications\YourNotification::class); + Mail::assertSentCount(1); } /** @@ -47,7 +52,7 @@ class SubscribableNotificationTest extends TestCase */ public function test_globally_unsubscribed_user_does_not_receive_notification(): void { - Notification::fake(); + Mail::fake(); Subscriber::fake()->alwaysUnsubscribed(); // TODO: Replace with your notifiable model factory @@ -56,8 +61,7 @@ class SubscribableNotificationTest extends TestCase // TODO: Replace with your notification class $user->notify(new \App\Notifications\YourNotification()); - // TODO: Replace with your notification class - Notification::assertNotSentTo($user, \App\Notifications\YourNotification::class); + Mail::assertNothingSent(); } /** @@ -69,11 +73,11 @@ class SubscribableNotificationTest extends TestCase */ public function test_user_unsubscribed_from_mailing_list_does_not_receive_notification(): void { - Notification::fake(); + Mail::fake(); // alwaysUnsubscribed() makes both the global and per-list checks return false. - // To test only per-list gating, configure a real handler in your service provider - // that returns false only for the specific list. + // To test only per-list gating, configure real onCheckSubscriptionStatus* handlers + // in your test setup that return false only for the specific list. Subscriber::fake()->alwaysUnsubscribed(); // TODO: Replace with your notifiable model factory @@ -82,8 +86,7 @@ class SubscribableNotificationTest extends TestCase // TODO: Replace with a notification that implements AppliesToMailingList $user->notify(new \App\Notifications\YourNotification()); - // TODO: Replace with your notification class - Notification::assertNotSentTo($user, \App\Notifications\YourNotification::class); + Mail::assertNothingSent(); } /** From 6203eb45d0e0e96ad83f9d08d092d740bb69bd61 Mon Sep 17 00:00:00 2001 From: peterfox <1716506+peterfox@users.noreply.github.com> Date: Sun, 31 May 2026 19:54:18 +0000 Subject: [PATCH 23/25] Fix styling --- src/Subscriber.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Subscriber.php b/src/Subscriber.php index 662e446..76cd463 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -112,6 +112,7 @@ public function complete(mixed $user, ?string $mailingList = null): mixed if ($this->onCompletion === null) { throw new \LogicException('No completion handler registered. Call Subscriber::onCompletion() in your service provider.'); } + return ($this->onCompletion)($user, $mailingList); } From 7125928c94a74c1976f1b47f1cd6d1d32c907939 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 20:03:27 +0000 Subject: [PATCH 24/25] Fix Subscriber::fake() to replace DI bindings, not just the facade root 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 --- src/Facades/Subscriber.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Facades/Subscriber.php b/src/Facades/Subscriber.php index c0ee21c..2fff80a 100644 --- a/src/Facades/Subscriber.php +++ b/src/Facades/Subscriber.php @@ -4,6 +4,7 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Facade; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; use YlsIdeas\SubscribableNotifications\Testing\FakeSubscriber; /** @@ -34,6 +35,13 @@ protected static function getFacadeAccessor(): string public static function fake(): FakeSubscriber { $fake = new FakeSubscriber(); + + $app = static::getFacadeApplication(); + $app->forgetInstance(\YlsIdeas\SubscribableNotifications\Subscriber::class); + $app->forgetInstance(SubscriberContract::class); + $app->instance(\YlsIdeas\SubscribableNotifications\Subscriber::class, $fake); + $app->instance(SubscriberContract::class, $fake); + static::swap($fake); return $fake; From e9f25f86a0d0ba9498dae8e13811e24859b6908b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 20:14:04 +0000 Subject: [PATCH 25/25] Fix FakeSubscriber assertions to compare models by key, not object identity 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 --- src/Testing/FakeSubscriber.php | 38 +++++++++++++++++-- .../Controllers/UnsubscribeControllerTest.php | 18 +++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php index 1e74694..b3428f1 100644 --- a/src/Testing/FakeSubscriber.php +++ b/src/Testing/FakeSubscriber.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Assert; use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; -final class FakeSubscriber implements SubscriberContract +class FakeSubscriber implements SubscriberContract { public string $routeName = 'unsubscribe'; @@ -93,7 +93,7 @@ public function assertUnsubscribedFromMailingList(mixed $user, string $mailingLi { Assert::assertTrue( collect($this->unsubscribedFromMailingList) - ->contains(fn ($item) => $item['user'] === $user && $item['list'] === $mailingList), + ->contains(fn ($item) => $this->matches($item['user'], $user) && $item['list'] === $mailingList), "Failed asserting that the user was unsubscribed from [{$mailingList}]." ); } @@ -101,7 +101,7 @@ public function assertUnsubscribedFromMailingList(mixed $user, string $mailingLi public function assertUnsubscribedFromAll(mixed $user): void { Assert::assertTrue( - collect($this->unsubscribedFromAll)->contains($user), + collect($this->unsubscribedFromAll)->contains(fn ($item) => $this->matches($item, $user)), 'Failed asserting that the user was unsubscribed from all mailing lists.' ); } @@ -110,7 +110,7 @@ public function assertCheckedSubscriptionStatus(mixed $user, ?string $mailingLis { Assert::assertTrue( collect($this->subscriptionStatusChecks) - ->contains(fn ($item) => $item['user'] === $user && $item['mailingList'] === $mailingList), + ->contains(fn ($item) => $this->matches($item['user'], $user) && $item['mailingList'] === $mailingList), $mailingList !== null ? "Failed asserting that subscription status was checked for mailing list [{$mailingList}]." : 'Failed asserting that subscription status was checked for all mailing lists.' @@ -128,4 +128,34 @@ public function assertNothingUnsubscribed(): void 'Failed asserting that no all-mail unsubscribes occurred.' ); } + + public function getUnsubscribedFromMailingList(): array + { + return $this->unsubscribedFromMailingList; + } + + public function getUnsubscribedFromAll(): array + { + return $this->unsubscribedFromAll; + } + + public function getSubscriptionStatusChecks(): array + { + return $this->subscriptionStatusChecks; + } + + protected function matches(mixed $expected, mixed $actual): bool + { + if ($expected === $actual) { + return true; + } + + if ($expected instanceof \Illuminate\Database\Eloquent\Model && + $actual instanceof \Illuminate\Database\Eloquent\Model) { + return $expected->getMorphClass() === $actual->getMorphClass() + && (string) $expected->getKey() === (string) $actual->getKey(); + } + + return false; + } } diff --git a/tests/Controllers/UnsubscribeControllerTest.php b/tests/Controllers/UnsubscribeControllerTest.php index 7344851..2784e27 100644 --- a/tests/Controllers/UnsubscribeControllerTest.php +++ b/tests/Controllers/UnsubscribeControllerTest.php @@ -211,6 +211,24 @@ public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe_from_ $this->assertTrue($called); } + public function test_fake_assertions_work_when_model_is_loaded_fresh_by_the_controller() + { + // This exercises the model-identity problem: the controller loads the user via + // ->first(), producing a different object instance than $expectedUser. The fake's + // assertions must compare by primary key, not by object identity (===). + $fake = Subscriber::fake(); + + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + $this->get($expectedUser->unsubscribeLink())->assertSuccessful(); + + $fake->assertUnsubscribedFromAll($expectedUser); + } + public function test_it_rejects_post_without_rfc8058_body() { $expectedUser = DummyUser::create([