Laravel Badge

Guide

Releasing a Major Version: Guiding Users from v1 to v2

By Peter Fox

At some point every package grows past what its first design can comfortably hold. An API choice that made sense at launch now causes pain everywhere. A dependency dropped support for older PHP. You want to adopt constructor promotion and named arguments throughout, but the change would break every consumer overnight.

This is the moment a major version exists for. Semver says a bump from 1.x to 2.0.0 signals intentional, incompatible change. That contract and that the version number carries meaning. It is the only thing standing between your users and a composer update that silently breaks their production app. Honour it carefully.

This guide walks through the full lifecycle of a major upgrade: deciding what belongs in the break, preparing users in advance, writing an upgrade guide, shipping the release, and keeping a long-term support branch alive for those who can't move yet.


Meet the example package

Throughout this guide we'll use acme/laravel-notify, a fictional package that dispatches notifications through third-party channels. Its current public API looks roughly like this:

// v1 usage
Notify::send('slack', $message, ['channel' => '#general']);
Notify::send('email', $message, ['to' => 'ops@example.com']);

Version 2 is going to replace the stringly-typed channel name and flat options array with a proper Channel interface and dedicated DTOs. It's a meaningful improvement, but every call site in every app using the package needs to change.


Deciding what deserves a major version

Not every breaking change justifies a major bump. Removing a protected method that third parties realistically subclass does. Adding a required constructor parameter to a class that users instantiate directly does. Changing a method signature does. Renaming a config key does.

Things that sound breaking but typically aren't: adding an optional parameter with a default, widening a return type, adding a new interface method when you also ship a default implementation, or raising the minimum PHP version in a way that composer.json will refuse to install silently.

The question to ask is: will a consumer's code fail at runtime? Or fail static analysis? After a composer update? If yes, that's a breaking change. Collect them, because you want to do all your breaking work in one major release rather than two.

A practical checklist for the v2 scope:

  • Renamed or removed public methods and properties
  • Changed method signatures (new required parameters, changed types, changed return types)
  • Removed or renamed config keys
  • Renamed or removed artisan commands and their options
  • Changed exception types
  • Removed service container bindings or changed how they resolve
  • Changed event class names or their payloads
  • Raised minimum PHP or Laravel version

Write this list down. It becomes the skeleton of your upgrade guide later.


The deprecation cycle: warning before breaking

If your package has a meaningful user base, the most respectful path to a major version starts six to twelve months earlier with deprecations in a late 1.x release.

The idea is simple: ship the new API alongside the old one, mark the old one deprecated, and give users time to migrate before you remove it.

// v1.8 — the old method still works, but warns
/**
 * @deprecated Use send(Channel $channel) instead. Will be removed in v2.0.
 */
public function send(string $channel, string $message, array $options = []): void
{
    trigger_error(
        sprintf(
            '%s::%s is deprecated. Use send(Channel $channel) instead.',
            static::class,
            __FUNCTION__,
        ),
        E_USER_DEPRECATED,
    );

    $this->sendViaLegacy($channel, $message, $options);
}

// The new signature lives side by side
public function send(Channel $channel): void
{
    // new implementation
}

In practice, PHP doesn't let you have two methods of the same name, so you'll often introduce the new method under a temporary name, deprecate the old one pointing to it, then rename again in v2. The implementation detail matters less than the signal: users who run their test suite will see deprecation notices and know change is coming.

Tag that release, announce it, and link to a preview of the v2 upgrade guide. Developers who adopt early give you invaluable feedback before the final break.


Writing the upgrade guide

This is the single most important deliverable of a major release. An upgrade guide that takes a developer thirty minutes to follow is a successful major release. One that requires them to spelunk through diffs and changelogs for a day is not.

Create UPGRADE.md in the root of your repository. Version it: UPGRADE.md covers the most recent major transition; you can keep UPGRADE-1.x.md around for historical reference.

Structure your upgrade guide clearly

Open with the minimum requirements for v2 and the PHP and Laravel versions it supports. Users running older environments need to know immediately that v2 is not for them yet.

## Upgrading from v1 to v2

**Requirements:** PHP 8.2+, Laravel 11+

### High impact changes

- [Channel objects replace string channel names](#channel-objects)
- [Config key `notify.default_channel` renamed to `notify.channel`](#config-changes)

### Medium impact changes

- [Exception namespace changed](#exceptions)

### Low impact changes

- [`NotifyFacade` alias removed](#facade)

Group changes by impact. "High impact" means nearly every application will need to change something. "Low impact" means only applications using that specific feature are affected. The grouping lets a developer scan quickly and focus their effort.

For each change, show a before/after diff:

### Channel objects

**Before:**

```php
Notify::send('slack', $message, ['channel' => '#general']);
```

**After:**

```php
use Acme\Notify\Channels\SlackChannel;

Notify::send(new SlackChannel(channel: '#general', message: $message));
```

Include every renamed config key. Include every renamed artisan command. If an exception class moved to a different namespace, show the old and new import. If a service provider changed its binding key, show the old app('notify') call and the new one.

Err on the side of completeness. A change you considered "obvious" will be the one that costs someone an hour.

Automate what you can

For large packages with many call sites, consider shipping a Rector rule set alongside v2 that automates the migration. Rector can rename method calls, swap class names, and restructure argument lists. Even a rule that handles 80% of the changes automatically is an enormous time saving for your users.

// A minimal Rector rule for the channel rename
use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use Rector\Rector\AbstractRector;

final class NotifyStringToChannelRector extends AbstractRector
{
    public function getNodeTypes(): array
    {
        return [StaticCall::class];
    }

    public function refactor(Node $node): ?Node
    {
        // Identify and rewrite Notify::send('slack', ...) calls
        // ...
    }
}

Ship it in a rector-rules/ directory with a sample rector.php config. Users who can adopt it will thank you.


Versioning your composer.json correctly

Before tagging, make sure your composer.json version constraints are right:

{
    "name": "acme/laravel-notify",
    "require": {
        "php": "^8.2",
        "illuminate/support": "^11.0|^12.0"
    }
}

Raise php and illuminate/support minimums to only what v2 actually needs. Don't carry forward compatibility with PHP or Laravel versions you haven't tested against and won't be fixing bugs for.

If you publish a Laravel config, bump the config version comment or add a version note so users know to re-publish:

return [
    // v2 — re-publish this config if upgrading from v1
    'channel' => env('NOTIFY_CHANNEL', 'log'),
];

Tagging and releasing

Tag v2.0.0 with a full release on GitHub (or your preferred host). In the release notes, lead with the headline of what v2 is and the reason it exists, not just a list of changes:

v2.0.0 ships a first-class Channel API that replaces the string-based channel system from v1. Every notification now carries its own typed configuration, making IDE support and static analysis work properly for the first time.

Then link to the upgrade guide directly. The release notes are not the upgrade guide, keep them brief, keep the detail in UPGRADE.md.

Announce the release in the same channels you used to announce v1: your README, the Laravel ecosystem Discord, any newsletters or community posts where people discovered the package originally.


Maintaining a v1 LTS branch

Releasing v2 does not mean v1 is immediately abandoned. Many users will be stuck on Laravel 10 or PHP 8.1 for months. Abandoning them creates resentment and forks.

A lightweight commitment goes a long way: security fixes and critical bug fixes only, for twelve months.

Create a 1.x branch from your last v1 tag:

git checkout v1.9.3
git checkout -b 1.x
git push origin 1.x

In your README.md, document the support table clearly:

| Version | PHP      | Laravel  | Support               |
|---------|----------|----------|-----------------------|
| 2.x     | ^8.2     | ^11, ^12 | Active development    |
| 1.x     | ^8.1     | ^10, ^11 | Security fixes only, until June 2027 |

Set the default branch to 2.x (or main pointing to v2) so new visitors land on the right code. Update your CI so the 1.x branch runs its own test matrix, you don't want a dependency bump on the LTS branch to silently break it.

When the LTS window closes, update the table to "End of life" and archive the branch in GitHub's UI. Don't delete it! Old tags and branches should stay navigable.


Communicating throughout the process

A major release done in public earns far more trust than one that appears overnight. Typical communication beats:

  1. Deprecation release — "v1.8 is out. It introduces the new Channel API and deprecates the old string-based interface. v2.0 will remove the old interface. The upgrade guide preview is here."
  2. Beta tagv2.0.0-beta.1 on Packagist lets early adopters install and report issues before the stable release. Ask for feedback explicitly.
  3. Release candidatev2.0.0-rc.1 signals the API is frozen; only bugs will change before stable.
  4. Stable release — the announcement with the full changelog, upgrade guide link, and LTS commitment for v1.

This cadence gives your users multiple opportunities to prepare. It also surfaces bugs in the upgrade guide itself, someone will follow the beta guide and find a step that's wrong or missing.


What to do when users get stuck

Even a perfect upgrade guide will leave someone stuck. Set expectations in the guide itself:

## Getting help

If you hit an issue not covered here, open a [GitHub Discussion](https://github.com/acme/laravel-notify/discussions)
with the label `v2-upgrade`. Please include your v1 code and the error you're seeing.

Use GitHub Discussions rather than Issues for upgrade questions. Issues imply bugs, discussions imply conversation. Answering five people in a public thread helps the next fifty people who search before asking.

Watch for the same question appearing more than twice. If three people asked how to migrate a specific call pattern, that call pattern is missing from your upgrade guide. Add it.


A major release as a credibility signal

Done well, a v1-to-v2 upgrade establishes something important: that your package is maintained by someone who takes backwards compatibility seriously. The deprecation cycle, the upgrade guide, the LTS branch, the public communication. These are all signals that say this package is not going to break your app without warning.

That reputation compounds. Developers who trusted your v1-to-v2 path will trust your next major release too. They'll recommend the package to colleagues. They'll contribute bug reports instead of quiet forks.

The work of a major release is mostly writing and communication, not code. The code changes are often the easiest part.