vanilo/channel
Vanilo Channel adds sales channels to Laravel e-commerce applications built with Vanilo. Define multiple storefronts or markets and scope products, pricing, and availability to each channel for multi-store and multi-region selling.
vanilo/channel is a sub-module of the Vanilo commerce suite, not a standalone library. In practice you almost never install it directly — it arrives as a dependency of vanilo/framework, so the first step is to confirm it is already there:
composer show vanilo/channel
php artisan migrate # creates `channels` and `channelables`
Only add it explicitly if you are assembling a custom stack:
composer require vanilo/channel
php artisan vendor:publish --tag=vanilo-channel # or via the installer / module provider
Publish the config before you change anything; the module is configured through config/vanilo.channel.php (model class override, channelable model map). Then read the source in this order — it is small enough to read end to end in 20 minutes:
src/Contracts/Channel.php — the interface every consumer type-hints against.src/Models/Channel.php — Eloquent implementation, the configuration bag, and the current() proxy.src/ChannelState.php — Konekt enum (Draft / Active / Inactive), not a native PHP enum.src/ChannelContext.php + its contract — the container-bound "which channel am I in" singleton.src/Traits/Channelable.php — the polymorphic many-to-many side you attach to Product, Order, etc.The canonical first use case is a single middleware that resolves a channel per request and puts it in context:
use Vanilo\Channel\Contracts\ChannelContext;
use Vanilo\Channel\Models\Channel;
public function handle($request, Closure $next)
{
$channel = Channel::where('slug', $request->route('channel'))->firstOrFail();
app(ChannelContext::class)->setCurrentChannel($channel);
return $next($request);
}
Everything downstream — pricing, catalogs, cart, order attribution — reads from that context, so getting this one binding right is the whole onboarding.
Resolve once, read everywhere. The context object is a container singleton. Bind it in middleware (or a route-binding callback) and never re-resolve the channel inside controllers/services; inject ChannelContext and call getCurrentChannel(). Downstream code stays channel-agnostic and testable — swap the context in tests.
Attach models via the Channelable trait. Add it to Product, Order, and anything else that should be channel-aware, then register the type in the channelables map in config/vanilo.channel.php. The channel side then exposes relations named after the map keys ($channel->products(), $channel->orders()), which is the idiomatic way to build per-storefront queries:
$channel->products()->attach($product); // pivot row
$channel->products()->sync([$a, $b]); // idempotent set
Product::whereHas('channels', fn ($q) => $q->whereKey($channel->id))->get();
Use the configuration JSON bag for per-channel settings. Tax region, currency, payment gateway keys, theme name — store them on the channel rather than building a settings table. It is a JSON column exposed as an array-like object; wrap access in a small value-object or accessor on your own Channel subclass so nobody reaches into raw JSON keys in a controller.
Route model binding by slug. Channels are user-facing storefronts; bind them {channel} → slug rather than ID so URLs stay readable and stable across environments.
Always pass the channel to jobs and events. Queue workers, scheduled commands, and listeners start with no channel in context. Serialise $channel->id into the job payload and re-set the context in handle() as the first statement. Same for console commands — resolve a default channel explicitly.
Seed idempotently. Channel::updateOrCreate(['slug' => 'webshop'], [...]) in a seeder, then assign channelables in a second pass — this keeps local resets and new environments from creating duplicate storefronts.
Testing. Refresh the database, create an Active channel in setUp(), and set it on the context. For multi-channel behaviour, run the same assertion inside a loop over channels — this catches global-scope bugs early.
creating hook or a failing test rather than discovering it in production.Channel::current() is null in console, queues, and Nova/HTTP-free contexts. This is the single most common bug report. Never call it at the top level of a service provider or a model's boot().$channel->state is an enum object; $channel->state === 'active' is false. Use ->equals(ChannelState::ACTIVE()), the isActive()-style magic checks, or compare ->value(). Passing the raw string into a where() clause works because the enum stringifies — don't rely on that everywhere.ChannelContext is a singleton — beware Octane and long-running workers. State set during request A survives into request B. Either reset the context at the start of every request (in middleware, unconditionally) or bind it as a scoped instance. Symptom: users intermittently see another storefront's prices.Inactive state over deletion for anything that has ever processed an order — historical records point at the channel.channelables pivot has a unique constraint on (channel_id, channelable_type, channelable_id). attach() twice throws; use sync() or syncWithoutDetaching() in loops and idempotent imports.Channel contract, but the shipped migration won't know about your extra columns — ship your own migration and keep the contract intact so framework code keeps working.channels. If you already have a channels table (chat, broadcasting), rename via the config before migrating rather than after.vanilo/framework as the source of truth for docs. This package is a small internal module with no standalone documentation and effectively no independent dependents; examples and changelogs live in the umbrella repo and the source itself. Pin it to the framework version you run, and when behaviour surprises you, read src/Models/Channel.php directly — it is a few hundred lines.How can I help you explore Laravel packages today?