NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Shops for Laravel

Shops & tenancy

A Shop is the concrete tenant every owned record belongs to through a plain, nullable shop_id foreign key. A single-shop app can ignore it entirely; a multi-shop app creates shops and scopes data to them:

use RoundlyConsulting\Shops\Facades\Shops;
use RoundlyConsulting\Shops\Products\Product;
use RoundlyConsulting\Shops\Shops\Shop;

$shop = Shop::create(['name' => 'Acme EU', 'currency' => 'EUR']);
$shop->currency();   // Currency (EUR) — the shop's own, or the configured default when null

// Explicit ownership always wins:
$product = Product::create(['shop_id' => $shop->id, 'name' => 'Sparkling Water']);

// Or bind a current shop and let shop_id auto-fill on create:
Shops::current()->set($shop);                        // a Shop or its id
Product::create(['name' => 'Still Water']);          // shop_id auto-filled
Shops::current()->id();                              // ?int
Shops::current()->forget();

// Scoped block — restores the previous binding afterwards (even on exception):
Shops::current()->run($shop, function () {
    Product::create(['name' => 'Tonic']);            // belongs to $shop
});

Shops::current()->get();                             // ?Shop bound to the current context
Shop::current();                                     // the same, typed to the package's Shop model

Product::query()->forShop($shop)->get();             // scope by model
Product::query()->forShop($shop->id)->get();         // or by id

The Shop model

Shop has a translatable name and slug, a nullable ISO currency and soft deletes. Its slug is unique across all shops and is the route key:

$shop->setTranslation('name', 'sk', 'Acme EU (SK)')->save();

$shop->products();     // HasMany<Product>  — also what ->scopeBindings() walks
$shop->categories();   // HasMany<Category> — ditto
$shop->taxRates();     // HasMany<TaxRate>
$shop->currency();     // Currency — the shop's, else shops.pricing.default_currency
$shop->currentSlug();  // sluggable readers: slugMap(), slugFor($locale)

Shop::resolveModelClass();   // the configured shops.shop_model class
Shop::current();             // ?Shop — the CurrentShop binding, only when it is a Shop

CurrentShop

Shops::current() returns CurrentShop, which holds the active tenant. It is bound per request and per queued job (a scoped binding): Octane drops it between requests and the queue worker before each job, so one tenant never leaks into the next. Nothing sets it for you — set it in your own middleware, at the top of a job or in a scoped block. Without the facade, call ->current() on an injected ShopsManager or resolve CurrentShop itself:

MethodDescription
set(Model|int|null $shop): voidBind a model or a key.
get(): ?ModelThe bound model; a bound key is loaded once.
id(): ?intThe bound key.
forget(): voidClear the binding.
run(Model|int $shop, Closure $callback): mixedBind for the callback, restore the previous binding afterwards (also on exceptions), return the callback’s value.
use Closure;
use Illuminate\Http\Request;
use RoundlyConsulting\Shops\Facades\Shops;

final class SetCurrentShop
{
    public function handle(Request $request, Closure $next)
    {
        Shops::current()->set($request->route('shop'));   // a model or a key

        return $next($request);
    }
}

// In a queued job or console command, scope the work instead:
$product = Shops::current()->run($shop, fn () => Product::create(['name' => 'Chair']));

BelongsToShop

  • Used by Product, Category, Cart, Order and order Item. TaxRate has its own shop() relation.
  • On creating, a null shop_id is filled from CurrentShop. An explicit shop_id always wins; with nothing bound, nothing is filled.
  • shop() — a BelongsTo to the configured tenant model; forShop($shopOrId) — a where on shop_id.
  • Variants, options, stock adjustments and cart items hang off their parent and carry no shop_id.

Your own tenant model

shops.shop_model accepts any Eloquent model — owned records only need its primary key. Features that need the package’s Shop (Shop::current(), per-shop currency, per-shop database tax rates, the cart and order price shop context) engage only when the tenant is a Shop or a subclass; otherwise the config default currency and config tax classes apply. Extending Shop keeps them all:

use RoundlyConsulting\Shops\Shops\Shop;

// Extend the packaged model to keep currency, tax rates, translations,
// slugs and Shop::current():
class Store extends Shop
{
    // your own relations and helpers
}

// config/shops.php
'shop_model' => App\Models\Store::class,

The published migrations constrain every shop_id to the shops table. A tenant model backed by another table needs those foreign keys adjusted in your published migrations.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.