|
| 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 | +``` |
0 commit comments