Documentation v1.0.0

GhostGrid

GhostGrid is a Laravel backend package for tenant-based reseller commerce. It handles account hierarchy, providers, products, pricelists, storefront display products, order snapshots, provisioning tasks, and services.

Backend package only. GhostGrid gives you models, helpers, services, actions, jobs, commands, migrations, tests, and docs. It does not force an admin panel or a fixed reseller hierarchy.

Supported Laravel versions: 10, 11, 12, and 13. Laravel 13 is tested on PHP 8.3+.

Installation

composer require ghostcompiler/ghostgrid
php artisan gg:init
php artisan migrate

Manual publish is also available.

php artisan vendor:publish --tag=ghostgrid-config
php artisan vendor:publish --tag=ghostgrid-migrations

User Setup

Add the trait to your Laravel User model.

use GhostCompiler\GhostGrid\Support\Concerns\HasGhostGrid;

class User extends Authenticatable
{
    use HasGhostGrid;
}

Now normal controllers can use the short helper.

$priceLists = ghostgrid()->priceLists();
$products = ghostgrid()->visibleStorefrontProducts();

In jobs, seeders, or tests, pass the user manually.

$priceLists = ghostgrid($user)->priceLists();

Simple API

This is the recommended public API for application code. The helper automatically scopes reads and writes to accounts linked to the current user.

$grid = ghostgrid();

$account = $grid->account();
$accounts = $grid->accounts();

$priceLists = $grid->priceLists();
$active = $grid->activePriceList();
$assigned = $grid->assignedPriceLists();
$drafts = $grid->unassignedPriceLists();
$priceList = $grid->priceList('Default VPS Pricing');
What does user mean? It is your Laravel authenticated user. GhostGrid links that user to one or more commerce accounts through account_users.

Mental Model

Your Laravel user logs in. The GhostGrid account owns commerce data.

graph TD User["Laravel User"] --> Link["account_users"] Link --> Account["GhostGrid Account"] Account --> PriceLists["Pricelists"] Account --> Storefront["Storefront Products"] Account --> Orders["Orders"] Account --> Services["Services"]

This is why account_id means accounts.id, not users.id.

Accounts

Accounts are flexible. GhostGrid does not force superadmin -> reseller -> client. You can build any tree.

$operator = ghostgrid()->createAccount([
    'name' => 'Operator',
    'type' => 'operator',
]);

$client = ghostgrid($clientUser)->createAccount(
    attributes: ['name' => 'Client Website', 'type' => 'client'],
    parent: $operator,
);
graph TD A["Operator Account"] --> B["Reseller Account"] B --> C["Sub Reseller Account"] C --> D["Client Account"]

Providers

A provider database record stores provider identity. A provider adapter class executes dynamic provisioning actions.

$provider = ghostgrid()->createProvider('hetzner', 'Hetzner');
php artisan gg:vendor Hetzner
'provider_registry' => [
    'hetzner' => App\GhostGrid\Providers\HetznerProvider::class,
],

Products

Products are catalog groups. Variants are the sellable items. Provider mappings connect variants to provider SKUs.

[$product, $variant] = ghostgrid()->createProduct(
    product: [
        'name' => 'Cloud VPS',
        'slug' => 'cloud-vps',
        'type' => 'vps',
    ],
    variant: [
        'name' => 'Hetzner CX32',
        'slug' => 'cx32',
        'billing_cycle' => 'monthly',
        'specs' => ['ram' => '4 GB', 'disk' => '80 GB SSD'],
    ],
    provider: $provider,
    mapping: [
        'provider_sku' => 'cx32',
        'priority' => 100,
        'config' => ['region' => 'fsn1'],
    ],
);
graph LR Product["Product: Cloud VPS"] --> Variant["Variant: Hetzner CX32"] Variant --> Mapping["Provider Mapping"] Mapping --> Provider["Provider: Hetzner"]

Pricelists

Create and assign a pricelist to the current user's account.

$priceList = ghostgrid()->createPriceList('Default VPS Pricing', [
    [
        'product_variant_id' => $variant->id,
        'billing_cycle' => 'monthly',
        'selling_price' => 1499,
        'setup_fee' => 0,
    ],
]);

Create a draft without assigning it.

$draft = ghostgrid()->createUnassignedPriceList('Draft Sale Pricing', [
    [
        'product_variant_id' => $variant->id,
        'billing_cycle' => 'monthly',
        'selling_price' => 1299,
    ],
]);

Assign it later.

$assignment = ghostgrid()->assignPriceList($draft);

Assign with a date range.

$assignment = ghostgrid()->assignPriceList(
    priceList: $draft,
    account: $clientAccount,
    isDefault: true,
    startsAt: '2026-06-01 00:00:00',
    endsAt: '2026-06-30 23:59:59',
);

Master Pricelist

A master pricelist is the source of products a tenant may sell. Tenant pricelists can copy those products and activate only the items they price.

$tenantPriceList = ghostgrid()->createPriceListFromParent(
    parent: $masterPriceList,
    name: 'Tenant VPS Pricing',
    itemOverrides: [
        $variant->id => [
            'selling_price' => 1499,
            'enabled' => true,
        ],
    ],
);

If a tenant does not set a product price, the item is copied but inactive.

[
    'product_variant_id' => $variant->id,
    'selling_price' => '1000.00',
    'enabled' => false,
    'metadata' => [
        'inherited_from_parent' => true,
        'tenant_price_set' => false,
    ],
]
$createdInactiveItems = ghostgrid()->syncPriceListWithParent($tenantPriceList);
graph TD Master["Master Pricelist"] --> ItemA["Product A"] Master --> ItemB["Product B"] ItemA --> TenantA["Tenant Item A: active price set"] ItemB --> TenantB["Tenant Item B: inactive enabled=false"]

Storefront Products

Storefront products are frontend display records. They do not replace backend product variants.

$storefrontProduct = ghostgrid()->createStorefrontProduct([
    'product_variant_id' => $variant->id,
    'price_list_id' => $priceList->id,
    'name' => 'Premium VPS 4GB',
    'slug' => 'premium-vps-4gb',
    'description' => 'Fast cloud server for growing sites',
    'actual_price' => 2000,
    'price' => 1499,
    'popular' => true,
    'visible' => true,
    'featured' => true,
    'features' => [
        ['label' => '4 GB RAM', 'icon' => 'server'],
        ['label' => '80 GB SSD', 'icon' => 'hard-drive'],
        ['label' => 'Free Setup', 'icon' => null],
    ],
]);
$products = ghostgrid()->visibleStorefrontProducts();
graph LR Variant["Backend Variant: Hetzner CX32"] --> Storefront["Storefront: Premium VPS 4GB"] Storefront --> Website["Your Website"]

Orders

Orders are created from the active assigned pricelist. GhostGrid snapshots product names, prices, setup fees, and provider mapping data at order time.

use GhostCompiler\GhostGrid\Actions\CreateOrderFromCart;

$order = app(CreateOrderFromCart::class)->handle(
    accountId: ghostgrid()->account()->id,
    cartItems: [
        [
            'product_variant_id' => $variant->id,
            'billing_cycle' => 'monthly',
            'quantity' => 1,
        ],
    ],
);

Payment To Provisioning

After payment succeeds, mark the order paid. GhostGrid can create provisioning tasks and dispatch the queue job.

$paidOrder = ghostgrid()->markOrderPaid(
    order: $order,
    createProvisioningTasks: true,
    dispatch: true,
);

Create tasks without dispatching.

$tasks = ghostgrid()->createProvisioningTasksForOrder(
    order: $order,
    dispatch: false,
);
graph TD Order["Order"] --> Paid["Payment Success"] Paid --> Task["Provisioning Task"] Task --> Job["ProvisioningJob"] Job --> Provider["Provider Adapter"] Provider --> Service["Service"]

Services

A successful provisioning create action creates or updates a service. Lifecycle helpers create provider action tasks.

ghostgrid()->suspendService($service, dispatch: true);
ghostgrid()->unsuspendService($service, dispatch: true);
ghostgrid()->renewService($service, dispatch: true);
ghostgrid()->terminateService($service, dispatch: true);

Update And Archive

Most helper methods accept a model instance or ID. For live commerce, prefer archive/disable over hard delete.

ghostgrid()->updatePriceList($priceList, ['name' => 'Published Pricing']);
ghostgrid()->archivePriceList($priceList);
ghostgrid()->deletePriceList($priceList);

ghostgrid()->addPriceListItem($priceList, [
    'product_variant_id' => $variant->id,
    'billing_cycle' => 'monthly',
    'selling_price' => 1600,
]);
ghostgrid()->disablePriceListItem($item);

ghostgrid()->hideStorefrontProduct($storefrontProduct);

Provider Adapters

Adapters define their own capabilities. Core GhostGrid does not need updates for new provider actions.

use GhostCompiler\GhostGrid\DTO\ProvisioningResult;

interface ProvisioningProvider
{
    public function code(): string;
    public function name(): string;
    public function capabilities(): array;
    public function supports(string $action): bool;
    public function execute(string $action, array $payload): ProvisioningResult;
}
use GhostCompiler\GhostGrid\Services\ProvisioningManager;

$result = app(ProvisioningManager::class)->execute(
    providerCode: 'hetzner',
    action: 'reboot',
    payload: ['remote_id' => 'server-123'],
);

Configuration

GhostGrid exposes only options users should customize.

return [
    'id_strategy' => env('GHOSTGRID_ID_STRATEGY', 'uuid'),
    'default_currency' => env('GHOSTGRID_DEFAULT_CURRENCY', 'USD'),
    'allow_selling_below_parent' => env('GHOSTGRID_ALLOW_SELLING_BELOW_PARENT', false),
    'queue_connection' => env('GHOSTGRID_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'sync')),
    'provider_registry' => [],
    'model_overrides' => [],
    'storefront' => [
        'default_visible' => true,
        'default_featured' => false,
    ],
    'enabled_modules' => [
        'accounts' => true,
        'providers' => true,
        'pricing' => true,
        'storefront' => true,
        'orders' => true,
        'provisioning' => true,
        'services' => true,
    ],
];

ID Strategy

UUID IDs are the default. Choose the ID strategy before running migrations.

GHOSTGRID_ID_STRATEGY=uuid
# uuid, ulid, bigint
  • uuid: string UUID primary keys.
  • ulid: string ULID primary keys.
  • bigint: auto-incrementing big integer primary keys.
  • Relationship columns follow the same strategy and are indexed.

No Foreign Keys

GhostGrid does not create database foreign key constraints. This keeps tenant movement, provider imports, recovery, and reseller data operations flexible.

Migrations use indexed columns, but never foreign(), constrained(), references(), cascadeOnDelete(), or nullOnDelete().

Commands

php artisan gg:init
php artisan gg:vendor Hetzner
php artisan gg:sync hetzner
php artisan gg:demo
php artisan gg:check

Helper Cheatsheet

$grid = ghostgrid();

$grid->createAccount([...]);
$grid->account();
$grid->accounts();
$grid->createChildAccount([...]);
$grid->archiveAccount($account);

$grid->createProvider('hetzner', 'Hetzner');
$grid->createProduct($product, $variant, $provider, $mapping);

$grid->priceLists();
$grid->activePriceList();
$grid->assignedPriceLists();
$grid->unassignedPriceLists();
$grid->priceList($idOrName);
$grid->createPriceList('Default', $items);
$grid->createUnassignedPriceList('Draft', $items);
$grid->createPriceListFromParent($masterPriceList, 'Tenant Pricing', $overrides);
$grid->syncPriceListWithParent($tenantPriceList);
$grid->assignPriceList($priceList);
$grid->archivePriceList($priceList);
$grid->disablePriceListItem($item);

$grid->createStorefrontProduct([...]);
$grid->visibleStorefrontProducts();
$grid->hideStorefrontProduct($storefrontProduct);

$grid->markOrderPaid($order, createProvisioningTasks: true, dispatch: true);
$grid->createProvisioningTasksForOrder($order);
$grid->suspendService($service);
$grid->unsuspendService($service);
$grid->renewService($service);
$grid->terminateService($service);

Raw Models

Helpers are recommended for tenant-aware app code. Raw Eloquent models and action classes are available for seeders, admin tools, and custom workflows.

use GhostCompiler\GhostGrid\Models\Account;
use GhostCompiler\GhostGrid\Models\PriceList;

$account = Account::create(['name' => 'Acme Hosting']);

$priceList = PriceList::create([
    'account_id' => $account->id,
    'name' => 'Default Pricing',
    'currency' => 'USD',
]);

Factories

use GhostCompiler\GhostGrid\Models\Account;
use GhostCompiler\GhostGrid\Models\Product;
use GhostCompiler\GhostGrid\Models\ProductVariant;
use GhostCompiler\GhostGrid\Models\PriceList;
use GhostCompiler\GhostGrid\Models\StorefrontProduct;

$account = Account::factory()->create();
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
$priceList = PriceList::factory()->create(['account_id' => $account->id]);
$storefront = StorefrontProduct::factory()->create([
    'account_id' => $account->id,
    'product_variant_id' => $variant->id,
]);

Database Schema

GhostGrid ships tables for accounts, account users, providers, credentials, products, variants, mappings, pricelists, items, assignments, storefront products, orders, order items, provisioning tasks, and services.

graph TD accounts -. indexed .- account_users accounts -. indexed .- price_lists price_lists -. indexed .- price_list_items product_variants -. indexed .- price_list_items product_variants -. indexed .- product_provider_mappings accounts -. indexed .- storefront_products accounts -. indexed .- orders orders -. indexed .- order_items order_items -. indexed .- provisioning_tasks order_items -. indexed .- services

Testing

composer validate --strict
composer test

The repository includes GitHub Actions matrix coverage for Laravel 10, 11, 12, and 13.

FAQ

How do I get pricelists created by the user?

Use ghostgrid()->priceLists(). It returns pricelists from accounts linked to the logged-in user.

How do I create a pricelist but not assign it?

Use ghostgrid()->createUnassignedPriceList(...) or createPriceList(..., assign: false).

How do I assign an existing pricelist?

Use ghostgrid()->assignPriceList($priceList).

Does GhostGrid include a dashboard?

No. GhostGrid is a backend package. Your Laravel app controls UI, routes, policies, and billing UX.

Why no foreign keys?

Reseller systems often need imports, account movement, external provider sync, and flexible recovery. GhostGrid uses indexed relationship columns and leaves enforcement to application logic.