# Gotcha: A New Plugin Migration File Does Nothing Until It's Registered By Name

Recurring issue — has bitten this codebase more than once (most recently the Shopify order
discount migration, 2026-09-30; see `reimporting-shopify-orders.md`). Read this before adding any
migration to any `plugins/webkul/*` plugin.

**Two completely separate causes produce the exact same "Nothing to migrate" symptom.** The one
below (`hasMigrations([...])` filename registration) is the first and more common one. The second,
found while building the `profit-loss` plugin (2026-09-30), only bites a **brand new** plugin —
see "Second, separate cause" at the bottom.

## The symptom

You add a new migration file to a plugin's `database/migrations/` folder. `php artisan migrate
--force` says:

```
INFO  Nothing to migrate.
```

— even though `ls plugins/webkul/<plugin>/database/migrations/` clearly shows the file sitting
right there. No error, no warning, nothing pointing at the real cause. It's very easy to go chasing
the wrong thing here — wrong branch deployed, git pull didn't run, wrong database connection,
stale opcache — none of which is the actual problem.

## The actual cause

Every plugin here extends `Webkul\Support\PackageServiceProvider`
(`plugins/webkul/support/src/PackageServiceProvider.php`, itself a thin wrapper around Spatie's
`laravel-package-tools`). Unlike plain Laravel, **it does not auto-discover everything in
`database/migrations/`.** It only calls `loadMigrationsFrom()` for migration filenames it's
explicitly told about, one by one, via `->hasMigrations([...])` in that plugin's own
`configureCustomPackage()` method. A file with no matching entry in that array is invisible to
`php artisan migrate` — forever, on every environment, with zero error output.

Example (`plugins/webkul/channel-orders/src/ChannelOrdersServiceProvider.php`):

```php
$package->name(static::$name)
    ->hasMigrations([
        '2026_04_10_000001_create_channel_orders_orders_table',
        '2026_04_10_000002_create_channel_orders_order_lines_table',
        // ...every other migration this plugin has ever shipped...
        '2026_09_25_000001_add_marketplace_dispatch_to_channel_orders_orders',
        '2026_09_30_000001_add_discount_to_channel_orders_orders',   // ← must be added here too
    ])
    ->runsMigrations()
```

Filenames go in **without** the `.php` extension, matching the actual file on disk exactly.

## The fix

1. Find the plugin's own `*ServiceProvider.php` (`plugins/webkul/<plugin>/src/`).
2. Find its `configureCustomPackage()` method and the `->hasMigrations([...])` array inside it.
3. Add the new migration's filename (no `.php`) to that array, in the same commit as the migration
   file itself — not as a follow-up, it's trivial to forget otherwise.
4. Re-run `php artisan migrate --force` (locally first) and confirm it actually runs, then confirm
   via `php artisan migrate:status | grep <name>` that it shows `Ran`.

## Why local testing can miss this entirely

`php artisan migrate --path=database/migrations/2026_..._whatever.php` (an explicit `--path`)
**bypasses the registration list completely** — Laravel will happily run a file directly off disk
regardless of whether any ServiceProvider knows about it. This is exactly how the discount
migration passed local testing while being silently broken everywhere else: it was verified with
`--path=`, not a plain `migrate --force`. **Always do a final check with a plain, unqualified
`php artisan migrate --force` (or at least `migrate:status`) before considering a new plugin
migration actually done** — `--path` proves the SQL itself is valid, not that the migration will
ever run as part of a normal deploy.

## Scope

39 plugins currently extend `PackageServiceProvider` (checked via `grep -rl "extends
PackageServiceProvider" plugins/`) — this applies to all of them, not just channel-orders. Some
plugins (e.g. `products`) document their own `hasMigrations` list inline in their own
Claude_help note (see `product_categories.md`, "Migrations registered in ServiceProvider" section)
— check whether the plugin you're touching already has one before assuming this is undocumented
for it.

## Second, separate cause: a brand-new plugin is never "installed"

Only bites you the first time a **new** plugin's migrations are added — an existing, already-used
plugin (like `channel-orders` above) is unaffected by this one.

`Webkul\Support\PackageServiceProvider::boot()` (`plugins/webkul/support/src/PackageServiceProvider.php:96`)
only calls `loadMigrationsFrom()` when `$this->package->isInstalled()` is true — even with
`->hasMigrations([...])` correctly listing every filename and `->runsMigrations()` set. That check
(`Package::isPluginInstalled()`, `plugins/webkul/support/src/Package.php:197`) queries a **real DB
table**, `plugins`, for a row matching the plugin's name with `is_installed = true`. A plugin that
was just created (composer.json + ServiceProvider + `bootstrap/providers.php` +
`bootstrap/plugins.php` all correctly wired) has **no such row yet** — so `hasMigrations()` is
entirely correct and still nothing runs. `class_exists()` on the plugin's classes will succeed
(autoloading isn't the problem), and `migrate:status` won't even list the migration as pending —
it's invisible to the migrator, not merely un-run.

Normally a human marks a plugin installed by clicking **Install** on its row in
Connectors/Plugin Manager (`plugins/webkul/plugin-manager/src/Filament/Resources/PluginResource.php:129`),
which runs `php artisan {plugin}:install` then sets `is_installed=true, is_active=true` on that
`plugins` row. Doing this by hand (e.g. building a plugin server-side via Claude Code, no browser
available) — mirror it exactly via tinker rather than reaching for `migrate --path=` again (same
"proves the SQL, not the real deploy path" trap as above):

```php
use Webkul\Support\Models\Plugin;
$composerData = json_decode(file_get_contents(base_path('plugins/webkul/<plugin>/composer.json')), true);
Plugin::updateOrCreate(
    ['name' => '<plugin>'],
    [
        'author'         => $composerData['authors'][0]['name'] ?? 'Cydekick',
        'summary'        => $composerData['description'] ?? '',
        'description'    => $composerData['description'] ?? '',
        'latest_version' => '1.0.0',
        'license'        => 'MIT',
        'is_active'      => true,
        'is_installed'   => true,
        'sort'           => 1,
    ]
);
```

Then a plain `php artisan migrate --force` picks the migrations up correctly, exactly as it would
in production — **this same tinker snippet (or the real Install button) is needed on every other
environment** the new plugin gets deployed to, same as any other install step.
