Skip to content

Commit 50f5f5d

Browse files
committed
feat: add legacy route support for v1 → v2 production upgrades, add UPGRADE.md
Subscriber::legacyRoutes(User::class) registers a backward-compatible route at the old URL shape (unsubscribe/{id}/{list?}) that resolves the subscriber type from the provided model class. Since signed URL signatures are over the URL path (not the route name), v1 links already in inboxes validate correctly against the legacy route. A where constraint on {subscriberType} prevents the new route from accidentally matching numeric v1-style IDs: the pattern [^\d/][^/]* ensures the first segment starts with a non-digit and contains no slashes. Also adds UPGRADE.md covering all breaking changes, the route migration strategy, and optional v2 improvements (enums, Subscriber::fake()). https://claude.ai/code/session_01R4pAjWwGY8xKspsdU8xnsy
1 parent 4e0dc8b commit 50f5f5d

5 files changed

Lines changed: 392 additions & 14 deletions

File tree

UPGRADE.md

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Upgrade Guide
2+
3+
## v1 → v2
4+
5+
### Overview of breaking changes
6+
7+
| Area | v1 | v2 |
8+
|---|---|---|
9+
| PHP | 8.1+ | **8.4+** |
10+
| Laravel | 9 / 10 / 11 | **12 / 13** |
11+
| Service provider | Extend abstract `SubscribableApplicationServiceProvider` | Publish a plain stub and fill it in |
12+
| Model binding | Single model class configured via `Subscriber::userModel()` | Polymorphic — any model works |
13+
| Unsubscribe URL shape | `unsubscribe/{id}/{list?}` | `unsubscribe/{type}/{id}/{list?}` |
14+
| Notification opt-in | Automatic for all `mail` channel notifications | Explicit — use `SubscribableMailMessage::via()` in `toMail()` |
15+
16+
---
17+
18+
### Step 1 — Update composer.json
19+
20+
```bash
21+
composer require ylsideas/subscribable-notifications:^2.0
22+
```
23+
24+
---
25+
26+
### Step 2 — Replace SubscribableApplicationServiceProvider
27+
28+
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.
29+
30+
**Remove** your existing implementation (typically `App\Providers\SubscribableServiceProvider`):
31+
32+
```php
33+
// v1 — delete this
34+
class SubscribableServiceProvider extends SubscribableApplicationServiceProvider
35+
{
36+
protected $model = User::class;
37+
38+
public function onUnsubscribeFromMailingList($user, $mailingList): void { ... }
39+
public function onUnsubscribeFromAllMailingLists($user): void { ... }
40+
public function onCompletion($user, ?string $mailingList): RedirectResponse { ... }
41+
public function onCheckSubscriptionStatusForMailingLists($user, $mailingList): bool { ... }
42+
public function onCheckSubscriptionStatusForAllMailingLists($user): bool { ... }
43+
}
44+
```
45+
46+
**Publish** the new stub:
47+
48+
```bash
49+
php artisan vendor:publish --tag=subscriber-provider
50+
```
51+
52+
This creates `App\Providers\SubscribableServiceProvider`. Fill in the five callbacks — the logic you had in the abstract methods moves directly into `boot()`:
53+
54+
```php
55+
public function boot(): void
56+
{
57+
Subscriber::routes();
58+
59+
Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList): void {
60+
// e.g. $notifiable->subscriptions()->where('list', $mailingList)->delete();
61+
});
62+
63+
Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable): void {
64+
// e.g. $notifiable->subscriptions()->delete();
65+
});
66+
67+
Subscriber::onCompletion(function ($notifiable, ?string $mailingList) {
68+
return redirect('/');
69+
});
70+
71+
Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool {
72+
return true; // e.g. $notifiable->isSubscribedTo($mailingList)
73+
});
74+
75+
Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool {
76+
return true; // e.g. ! $notifiable->hasUnsubscribedFromAll()
77+
});
78+
}
79+
```
80+
81+
Register the provider in `bootstrap/providers.php` if it isn't there already.
82+
83+
---
84+
85+
### Step 3 — Update your subscriber model
86+
87+
Remove any `Subscriber::userModel(User::class)` calls — this method no longer exists. The model is now resolved polymorphically from the URL.
88+
89+
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.
90+
91+
```php
92+
use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe;
93+
use YlsIdeas\SubscribableNotifications\MailSubscriber;
94+
95+
class User extends Authenticatable implements CanUnsubscribe
96+
{
97+
use MailSubscriber;
98+
}
99+
```
100+
101+
**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:
102+
103+
```php
104+
// AppServiceProvider::boot()
105+
use Illuminate\Database\Eloquent\Relations\Relation;
106+
107+
Relation::morphMap([
108+
'user' => \App\Models\User::class,
109+
]);
110+
```
111+
112+
With this in place, URLs contain `user` instead of the full class name, and refactoring or renaming your model won't invalidate existing links.
113+
114+
---
115+
116+
### Step 4 — Update your notifications
117+
118+
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`.
119+
120+
```php
121+
// v1
122+
use Illuminate\Notifications\Messages\MailMessage;
123+
124+
public function toMail(object $notifiable): MailMessage
125+
{
126+
return (new MailMessage())
127+
->subject('Your weekly digest')
128+
->line('Here is your digest...');
129+
}
130+
```
131+
132+
```php
133+
// v2
134+
use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage;
135+
136+
public function toMail(object $notifiable): SubscribableMailMessage
137+
{
138+
return SubscribableMailMessage::via($notifiable, $this)
139+
->subject('Your weekly digest')
140+
->line('Here is your digest...');
141+
}
142+
```
143+
144+
`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:
145+
146+
```php
147+
SubscribableMailMessage::via($notifiable, $this, 'my-theme::email')
148+
```
149+
150+
If you want to apply the same behaviour to a custom `MailMessage` subclass, use the `SubscribableNotification` trait directly instead of extending `SubscribableMailMessage`:
151+
152+
```php
153+
use Illuminate\Notifications\Messages\MailMessage;
154+
use YlsIdeas\SubscribableNotifications\Concerns\SubscribableNotification;
155+
156+
class MyMailMessage extends MailMessage
157+
{
158+
use SubscribableNotification;
159+
}
160+
```
161+
162+
---
163+
164+
### Step 5 — Handle the route URL change (critical for live systems)
165+
166+
This is the most important production concern. Every unsubscribe link already delivered to a user's inbox points at the v1 URL shape:
167+
168+
```
169+
# v1
170+
https://example.com/unsubscribe/123/newsletter?signature=...
171+
172+
# v2
173+
https://example.com/unsubscribe/user/123/newsletter?signature=...
174+
```
175+
176+
Those v1 links will stop working the moment you deploy v2 if nothing is done.
177+
178+
#### Option A — Register the legacy compatibility route (recommended)
179+
180+
Call `Subscriber::legacyRoutes()` alongside `Subscriber::routes()` in your service provider:
181+
182+
```php
183+
public function boot(): void
184+
{
185+
Subscriber::routes();
186+
Subscriber::legacyRoutes(App\Models\User::class); // default model for old links
187+
188+
// ... handlers
189+
}
190+
```
191+
192+
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.
193+
194+
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.
195+
196+
#### Option B — Accept that old links break
197+
198+
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.
199+
200+
---
201+
202+
### Step 6 — Optional: migrate mailing list identifiers to enums
203+
204+
v1 mailing lists were plain strings. v2 supports PHP 8.1 backed enums via the `AppliesToMailingList` contract:
205+
206+
```php
207+
// Before
208+
public function usesMailingList(): string
209+
{
210+
return 'newsletter';
211+
}
212+
213+
// After
214+
enum MailingList: string
215+
{
216+
case Newsletter = 'newsletter';
217+
}
218+
219+
public function usesMailingList(): string|\BackedEnum
220+
{
221+
return MailingList::Newsletter;
222+
}
223+
```
224+
225+
The enum's raw value (`'newsletter'`) is used in the URL, so existing links continue to work.
226+
227+
---
228+
229+
### Step 7 — Optional: use Subscriber::fake() in tests
230+
231+
v2 adds a test fake so you can assert unsubscribe behaviour without side effects:
232+
233+
```php
234+
$fake = Subscriber::fake();
235+
236+
// ... trigger unsubscribe ...
237+
238+
$fake->assertUnsubscribedFromMailingList($user, 'newsletter');
239+
$fake->assertUnsubscribedFromAll($user);
240+
$fake->assertNothingUnsubscribed();
241+
```
242+
243+
Control subscription status in tests:
244+
245+
```php
246+
$fake->alwaysSubscribed(); // all subscription checks return true
247+
$fake->alwaysUnsubscribed(); // all subscription checks return false
248+
```
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace YlsIdeas\SubscribableNotifications\Controllers;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Routing\Controller;
7+
use YlsIdeas\SubscribableNotifications\Subscriber;
8+
9+
class LegacyUnsubscribeController extends Controller
10+
{
11+
public function __construct(private readonly Subscriber $subscriber)
12+
{
13+
$this->middleware('signed');
14+
}
15+
16+
public function __invoke(Request $request, $subscriberId, ?string $mailingList = null)
17+
{
18+
return app(UnsubscribeController::class)(
19+
$request,
20+
$this->subscriber->legacySubscriberType,
21+
$subscriberId,
22+
$mailingList
23+
);
24+
}
25+
}

src/Facades/Subscriber.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* @see \YlsIdeas\SubscribableNotifications\Subscriber
1313
*
1414
* @method static void routes()
15+
* @method static void legacyRoutes(string $defaultModel)
1516
* @method static string routeName()
1617
* @method static void onCompletion(callable|string $handler)
1718
* @method static void onUnsubscribeFromMailingList(callable|string $handler)

src/Subscriber.php

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,18 @@
33
namespace YlsIdeas\SubscribableNotifications;
44

55
use Illuminate\Contracts\Foundation\Application;
6+
use Illuminate\Database\Eloquent\Relations\Relation;
67
use Illuminate\Http\Response;
78
use Illuminate\Support\Str;
89

910
class Subscriber
1011
{
11-
/**
12-
* @var string
13-
*/
1412
public $uri = 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}';
15-
/**
16-
* @var string
17-
*/
1813
public $hander = '\YlsIdeas\SubscribableNotifications\Controllers\UnsubscribeController';
19-
/**
20-
* @var string
21-
*/
2214
public $routeName = 'unsubscribe';
15+
16+
/** Morph type stored when legacyRoutes() is called — used by LegacyUnsubscribeController. */
17+
public ?string $legacySubscriberType = null;
2318
/**
2419
* @var callable
2520
*/
@@ -51,15 +46,26 @@ public function __construct(Application $app)
5146
$this->app = $app;
5247
}
5348

54-
public function routes($router = null)
49+
public function routes($router = null): void
5550
{
5651
$router = $router ?? $this->app->make('router');
52+
$router->match(['GET', 'POST'], $this->uri, $this->hander)
53+
->name($this->routeName)
54+
->where('subscriberType', '[^\d/][^/]*');
55+
}
56+
57+
public function legacyRoutes(string $defaultModel, $router = null): void
58+
{
59+
$router = $router ?? $this->app->make('router');
60+
61+
$morphMap = Relation::morphMap();
62+
$this->legacySubscriberType = array_search($defaultModel, $morphMap, true) ?: $defaultModel;
63+
5764
$router->match(
5865
['GET', 'POST'],
59-
$this->uri,
60-
$this->hander
61-
)
62-
->name($this->routeName);
66+
'unsubscribe/{subscriberId}/{mailingList?}',
67+
'\YlsIdeas\SubscribableNotifications\Controllers\LegacyUnsubscribeController'
68+
)->name($this->routeName . '.legacy');
6369
}
6470

6571
/**

0 commit comments

Comments
 (0)