# Migrating from Laravel Horizon

> Swap the Composer package, redeploy, restart the workers. No code changes, and no one-way door.

Source: https://boring-observability.dev/skyline/docs/migrating-from-horizon
Section: Getting started — Skyline for Laravel documentation
Updated: 2026-09-27

---

Migrating from Laravel Horizon to Skyline needs **no application code changes and no migrations**. Skyline keeps the `Laravel\Horizon\` namespace and declares `replace: laravel/horizon` in Composer, so every import, gate, config key and Artisan command you already have keeps resolving — against Skyline instead of Horizon. In practice the migration is a Composer swap, a deploy, and a worker restart.

## The migration

1. Remove `laravel/horizon` from the `require` block of `composer.json`.
2. Add the Anystack registry and require Skyline — see [Installation](https://boring-observability.dev/skyline/docs/installation) for the license-key setup.
3. Run `composer update`.
4. Deploy, then restart the workers with `php artisan horizon:terminate`.

```bash
composer remove laravel/horizon
composer config repositories.skyline composer https://laravel-skyline.composer.sh
composer require boring-o11y/laravel-skyline

php artisan horizon:terminate
```

Composer will refuse to install both packages at once. That is the intended behaviour rather than a conflict to work around — `replace` is what makes every third-party package that depends on `laravel/horizon` resolve cleanly against Skyline.

> **Which Horizon version Skyline replaces**
>
> The `replace` entry names *one exact* upstream release — the tag Skyline is currently rebased onto — rather than an open range, because claiming to satisfy versions of Horizon that do not exist is what broke resolution in 1.2.4. A package asking for `laravel/horizon: ^5.0` is therefore satisfied, and so is anything up to and including the replaced tag.
>
> What is **not** satisfied is a dependency asking for an upstream release newer than the one Skyline has rebased onto — `laravel/horizon: ^5.51` against a Skyline that replaces `5.50.0`, say. Composer will report that constraint as unresolvable until the next Skyline release catches the base up, which happens within 14 days of each upstream release. Your dashboard header names the Horizon version the installed copy replaces, and so does `composer show boring-o11y/laravel-skyline`.

## What keeps working, untouched

- Your `config/horizon.php`, copied across unchanged. Every Skyline key has a working default.
- Your `HorizonServiceProvider`, including `Gate::define('viewHorizon', …)` and `Horizon::auth()`.
- Every `use Laravel\Horizon\…` import in your application code.
- Every `php artisan horizon:*` command, by name and behaviour.
- The dashboard, still mounted at `/horizon`.
- The jobs already sitting in Redis: the payload format does not change, so nothing needs to drain first.
- The long wait notification, and the `routeMailNotificationsTo()` and `routeSlackNotificationsTo()` calls behind it. If you turn on Skyline's [alerts](https://boring-observability.dev/skyline/docs/alerts), its `queue_wait` check takes over from `LongWaitDetected` using the same `waits` thresholds and destinations, and Horizon's own notification stands down so you are not told twice. The event still fires for your own listeners.

> **Re-publish the dashboard assets**
>
> Skyline ships its own compiled dashboard assets. If your deploy publishes Horizon's assets into `public/`, add `php artisan horizon:publish` to it — otherwise you will be serving Horizon's old JavaScript against Skyline's API and wondering why the new tabs are missing.

## Deploying without dropping jobs

The swap is an ordinary deploy, and the ordinary deploy rules apply. Workers run the code they booted with, so a worker started before the deploy keeps running Horizon's code until it is restarted — which is exactly what `horizon:terminate` is for: it lets each worker finish the job in its hands and then exit, and your process supervisor restarts it on the new code.

There is no window in which jobs are lost. A job dispatched by new code and picked up by a not-yet-restarted worker is an ordinary Redis payload either way; the two versions read and write the same format.

## Verifying the migration

After the workers come back, three things tell you the swap took:

- `composer show boring-o11y/laravel-skyline` reports the installed version.
- The dashboard at `/horizon` shows the tabbed **Jobs** view, with Scheduled and Retries as separate tabs.
- Opening a queue from the dashboard drills into the jobs waiting inside it, rather than only counting them.

If the dashboard looks exactly as it did before, you are almost certainly serving stale published assets — see the note above.

## Rolling back

Rolling back is the same operation in reverse, and it is deliberately cheap:

```bash
composer remove boring-o11y/laravel-skyline
composer require laravel/horizon

php artisan horizon:publish
php artisan horizon:terminate
```

Skyline adds **no database schema** and stores its extra tracking data under the same Redis prefix Horizon already uses. A rollback therefore loses the Skyline-only views — the trend charts, the delayed-job index, the previous-attempt panel — and leaves your queues, your jobs and your metrics history untouched. The Skyline keys left behind in Redis are trimmed on their own retention schedule and cost nothing.

> **Config keys are safe to leave in place**
>
> A `queueWeights` map or a `job_arguments` block left in `config/horizon.php` after a rollback is simply ignored by upstream Horizon, which reads only the keys it knows about. You do not have to strip them out to go back.

## When the upgrade year ends

Already-installed versions keep running normally — there is no runtime license check that can interrupt job processing — and every release published during your twelve months stays installable. Only releases published after it fail to resolve, until you renew. An expired upgrade year is a build-time consideration, never a production one.


## Common questions

### Do I have to change any code to migrate from Horizon to Skyline?

No. Skyline keeps the Laravel\Horizon\ namespace and replaces laravel/horizon in Composer, so your config/horizon.php, HorizonServiceProvider, viewHorizon gate and every Laravel\Horizon\ import keep working untouched. There are no migrations to run.

### Can I roll back from Skyline to Horizon?

Yes, and it is the same operation in reverse: require laravel/horizon again and drop the Skyline package. Skyline adds no database schema and stores its extra tracking data under the same Redis prefix, so a rollback loses the Skyline-only views and leaves your queues and jobs untouched.

### Which version of laravel/horizon does Skyline replace?

Skyline's replace entry names the single upstream Horizon tag it is currently rebased onto, not an open range — 1.5.1 replaces 5.50.0. A package requiring laravel/horizon ^5.0 resolves against it, and so does anything up to that tag. A package requiring a release newer than the replaced tag does not, until the next Skyline release catches the base up; upstream releases are merged within 14 days. The installed copy names the Horizon version it replaces in the dashboard header.

### Will my queues drain or lose jobs during the migration?

No. The jobs already in Redis are untouched by the swap, because the payload format does not change. Migrating is an ordinary deploy: install the package, redeploy, then run php artisan horizon:terminate so the workers restart on the new code.
