Changelog
Release notes from the Larasell GitHub repository.
1.26.0 (2026-11-07)
⚠ BREAKING CHANGES
- remove product images from the core
Code Refactoring
- remove product images from the core (e42507f)
Upgrade guide
Product images are gone from the core
The core no longer stores product images. They will come back in a dedicated media module, so the old
ProductImagemodel and its placeholder system are removed instead of kept alongside.Who is affected
Apps that use any of these:
ProductImage,Product::images(),Product::$thumbnail,ProductVariant::$thumbnail,CreateProductImage,UpdateProductImage,DeleteProductImage,SetProductImages, theProductImageCreated/ProductImageUpdated/ProductImageDeletedevents,ImageId,ProductImageFileCast,PlaceholderCast,PlaceholderGeneratorand everything inLarasell\Larasell\Images, thelarasell:refresh-placeholderscommand, or thelarasell.imagesconfig.ProductUpdated::$changedno longer contains"images".A new migration drops the
larasell_product_imagesandlarasell_product_product_imagetables. Their rows are deleted; the files on the images disk are left in place.How to upgrade
Back up the two tables first if you need the data, remove any code using the classes above, remove
imagesfrom a publishedconfig/larasell.php, and run the migrations:1php artisan migrate1.25.0 (2026-11-07)
⚠ BREAKING CHANGES
- give custom URLs per channel and name the channel of a rejected one
- save custom URLs from attachments on the catalog payloads
- take payloads in the category actions
- move module exceptions to Exceptions namespaces as structured errors
- claim order numbers from one counter row per sequence
- check the sales channel key in CreateSalesChannel
Features
- check the sales channel key in CreateSalesChannel (e1a85c8)
- give custom URLs per channel and name the channel of a rejected one (85416e3)
- save custom URLs from attachments on the catalog payloads (5ba98ce)
- take payloads in the category actions (7fc571a)
Bug Fixes
- claim order numbers from one counter row per sequence (be853c3)
Code Refactoring
- move module exceptions to Exceptions namespaces as structured errors (b3eb8cc)
Upgrade guide
Category actions take payloads instead of attribute arrays
CreateCategory,UpdateCategoryandMoveCategorynow take typed payloads, so callers can't pass unknown or misspelled attributes. The payloads can carryAttachments, which the actions hand toCategoryCreatedandCategoryUpdatedfor an app's own listeners.MoveCategorynow also numbers the new siblings from 0 and puts the category at the given position.Who is affected
Apps that call
CreateCategory,UpdateCategory,MoveCategory,CreateCategoryWithUrlsorUpdateCategoryWithUrlsdirectly. Apps that rely onCategoryUpdatedonly firing when an attribute changed: it now also fires, with$changed === [], when attachments were passed.How to upgrade
1// Before 2app(CreateCategory::class)->handle(['name' => ['en' => 'Shoes'], 'parent_id' => $parent->id, 'status' => Visibility::Visible]); 3app(UpdateCategory::class)->handle(CategoryId::of($shoes), ['name' => ['en' => 'Footwear']]); 4app(MoveCategory::class)->handle(CategoryId::of($boots), CategoryId::of($sale), 2); 5 6// After 7use Larasell\Larasell\Catalog\Actions\CreateCategoryPayload; 8use Larasell\Larasell\Catalog\Actions\MoveCategoryPayload; 9use Larasell\Larasell\Catalog\Actions\UpdateCategoryPayload; 10use Larasell\Larasell\Translatable; 11 12app(CreateCategory::class)->handle(new CreateCategoryPayload( 13 name: new Translatable(['en' => 'Shoes']), 14 status: Visibility::Visible, 15 parentId: CategoryId::of($parent), 16)); 17app(UpdateCategory::class)->handle(CategoryId::of($shoes), new UpdateCategoryPayload(name: new Translatable(['en' => 'Footwear']))); 18app(MoveCategory::class)->handle(CategoryId::of($boots), new MoveCategoryPayload(CategoryId::of($sale), 2));CreateCategoryWithUrls::handle()andUpdateCategoryWithUrls::handle()take the same payloads in place of their$attributesarray.Custom URLs are attachments on the catalog payloads, and the
*WithUrlsworkflows are goneCreateCategoryWithUrls,UpdateCategoryWithUrls,CreateProductWithUrlsandUpdateProductWithUrlshave been removed. Instead, pass aLarasell\Larasell\Seo\Catalog\CustomUrlsattachment to the catalog action. The SEO module listens to the catalog events and saves the URLs inside the action's transaction, so a taken path still undoes the catalog write. Apps no longer need a second entry point for every catalog action.CreateProductandUpdateProductnow takeCreateProductPayloadandUpdateProductPayloadinstead of an attributes array.ProductCreatedandProductUpdatedcarry the payload'sAttachments.ProductUpdatednow also fires when only attachments were passed, with$changed === [].Who is affected
- Apps that call any of the four
*WithUrlsworkflows. - Apps that call
CreateProductorUpdateProductdirectly. - Apps with
ProductUpdatedlisteners that assume at least one attribute changed.
DiscontinueProductis unchanged. Apps that only listen toProductCreatedare not affected.How to upgrade
1// Before 2use Larasell\Larasell\Workflows\CreateCategoryWithUrls; 3use Larasell\Larasell\Workflows\UpdateProductWithUrls; 4 5app(CreateProduct::class)->handle(['name' => ['en' => 'Boots'], 'price' => Price::of(1000)]); 6app(UpdateProduct::class)->handle(ProductId::of($boots), ['sku' => 'B-2']); 7app(CreateCategoryWithUrls::class)->handle(new CreateCategoryPayload(new Translatable(['en' => 'Shoes'])), ['en' => 'footwear']); 8app(UpdateProductWithUrls::class)->handle(ProductId::of($boots), ['name' => ['en' => 'Leather Boots']], ['de' => 'winter-angebot', 'en' => null]); 9 10// After 11use Larasell\Larasell\Attachments\Attachments; 12use Larasell\Larasell\Catalog\Actions\CreateProductPayload; 13use Larasell\Larasell\Catalog\Actions\UpdateProductPayload; 14use Larasell\Larasell\Seo\Catalog\CustomUrls; 15 16app(CreateProduct::class)->handle(new CreateProductPayload(['name' => ['en' => 'Boots'], 'price' => Price::of(1000)])); 17app(UpdateProduct::class)->handle(ProductId::of($boots), new UpdateProductPayload(['sku' => 'B-2'])); 18app(CreateCategory::class)->handle(new CreateCategoryPayload( 19 new Translatable(['en' => 'Shoes']), 20 attachments: new Attachments(CustomUrls::byLocale(['en' => 'footwear'])), 21)); 22app(UpdateProduct::class)->handle(ProductId::of($boots), new UpdateProductPayload( 23 ['name' => ['en' => 'Leather Boots']], 24 new Attachments(CustomUrls::byLocale(['de' => 'winter-angebot', 'en' => null])), 25));CustomUrls::byLocale()writes a path to every channel that serves its locale. To give channels in the same locale their own paths, useCustomUrls::byChannel()with paths keyed by sales channel ID:1CustomUrls::byChannel('en', [$shop->id => 'footwear', $uk->id => 'trainers', $outlet->id => null]);Both throw an
InvalidArgumentExceptionfor a locale or channel with no URLs, before anything is written. A null path still goes back to the automatic URL.The workflows threw
InvalidSeoPathfor a rejected path. The catalog actions now throwLarasell\Larasell\Seo\Exceptions\CustomUrlRejectedinstead. It names the channel and locale ($context) and carries the$reasonand$path, with the originalInvalidSeoPathas its previous exception:1try { 2 app(UpdateCategory::class)->handle($id, $payload); 3} catch (CustomUrlRejected $exception) { 4 // e.g. show $exception->reason on the field of $exception->context->channelId 5}Module exceptions moved to
<Module>\Exceptionsand are Chronicle structured errorsEvery module now keeps its exceptions in its own
Exceptionsnamespace, and each one implements Chronicle'sStructuredException, so logs and error pages get an error code, a why and a fix. Messages are unchanged.Who is affected
Apps that import, catch or throw one of these classes:
| Before | After | | --- | --- | |
Larasell\Larasell\Ids\InvalidId|Larasell\Larasell\Ids\Exceptions\InvalidId| |Larasell\Larasell\SalesChannels\InvalidSalesChannel|Larasell\Larasell\SalesChannels\Exceptions\InvalidSalesChannel| |Larasell\Larasell\SalesChannels\SalesChannelInUse|Larasell\Larasell\SalesChannels\Exceptions\SalesChannelInUse| |Larasell\Larasell\Seo\InvalidSeoPath|Larasell\Larasell\Seo\Exceptions\InvalidSeoPath|InvalidSalesChannelis now built through named constructors only, e.g.InvalidSalesChannel::unknown('b2b').How to upgrade
1// Before 2use Larasell\Larasell\SalesChannels\InvalidSalesChannel; 3 4throw new InvalidSalesChannel('There is no sales channel [b2b].'); 5 6// After 7use Larasell\Larasell\SalesChannels\Exceptions\InvalidSalesChannel; 8 9throw InvalidSalesChannel::unknown('b2b');Update the other imports from the table the same way.
larasell_order_number_sequenceskeeps one counter row per sequenceThe table used to get a new row for every order number issued. Concurrent checkouts read the highest number, tried to insert the next one and retried on a collision, which deadlocked on MySQL and MariaDB and failed outright on PostgreSQL. Now each sequence is a single row holding the last number it issued, and
SingleOrderNumberSequenceandMultipleOrderNumberSequenceclaim the next number by incrementing that row inside the checkout's transaction. Concurrent checkouts wait for each other on that row instead of colliding.The PHP API is unchanged:
OrderNumberSequence::next()still returns the next number, honourslarasell.order_numbers.startthe same way, and order numbers continue where they left off. A checkout that rolls back now gives its number back, so numbers no longer skip after a failed checkout.Who is affected
Apps that read or write
larasell_order_number_sequencesthemselves, for example to count issued numbers or to look up when a number was issued. Itsidandcreated_atcolumns are gone, and it holds one row per sequence name with the last number innumber. Apps that only use the sequences or order numbers through Larasell are not affected; run the migration.How to upgrade
Run the migration. It keeps the highest issued number of every sequence, so the next order number follows the last one:
1php artisan migrateRead a sequence's last number from its row instead of from the newest row:
1// Before 2DB::table('larasell_order_number_sequences')->where('name', 'default')->max('number'); 3DB::table('larasell_order_number_sequences')->where('name', 'default')->count(); 4 5// After 6DB::table('larasell_order_number_sequences')->where('name', 'default')->value('number');The issued numbers themselves are on the orders, in
larasell_orders.number.CreateSalesChannelchecks the channel keyCreateSalesChannelnow refuses a key that isn't lowercase letters and numbers joined by-or_(e.g.b2b-shop), is longer than 64 characters, or another channel already has. It throwsInvalidSalesChannel, like for its other checks. Code names a channel by its key, as inSalesContext::for('b2b-shop'), so the rule belongs with the channel rather than with each form that creates one. A duplicate key used to fail with a databaseQueryException.Who is affected
Apps that call
Larasell\Larasell\SalesChannels\Actions\CreateSalesChannelwith a key in capitals, with spaces or other characters, or that catchQueryExceptionfor a duplicate key. Existing channels are not checked or changed; apps that already use keys likedefaultorb2b-shopcan skip this entry.How to upgrade
Pass a key in the new format, and catch
InvalidSalesChannelfor a duplicate:1use Larasell\Larasell\SalesChannels\Actions\CreateSalesChannel; 2use Larasell\Larasell\SalesChannels\InvalidSalesChannel; 3 4// Before 5try { 6 app(CreateSalesChannel::class)->handle(['key' => 'B2B Shop', /* … */]); 7} catch (\Illuminate\Database\QueryException) { 8 // the key was taken 9} 10 11// After 12try { 13 app(CreateSalesChannel::class)->handle(['key' => 'b2b-shop', /* … */]); 14} catch (InvalidSalesChannel $exception) { 15 // the key is taken or malformed; $exception->getMessage() says which 16}1.24.0 (2026-11-07)
⚠ BREAKING CHANGES
- storefront: let entry points serve a locale, not a currency
- pass typed IDs across module boundaries
- add typed IDs and take them in cart add, set and remove
- create carts and orders in an explicit sales context
- split the storefront off sales channels
- add the sales channel module
- keep SEO URLs in step through catalog events, in the same transaction
- move catalog models to Larasell\Larasell\Catalog\Models
- remove the model registry
- replace product and category slugs with SEO URLs
Features
- add the sales channel module (6fc4ccf)
- add the SEO module with sales channel entry points (a79b716)
- add workflows for discontinuing products and saving pages with custom URLs (b31a735)
- generate links to SEO URLs (014ac6a)
- keep SEO URLs in step through catalog events, in the same transaction (cb9fe9f)
- keep SEO URLs with history for products and categories (8d0d3b3)
- replace product and category slugs with SEO URLs (0886f2e)
- sales-channels: add DeleteSalesChannel (77d2e8f)
- seo: say why a path is invalid (40b0f5b)
- serve storefront pages through SEO URLs (0fe8a04)
- storefront: let entry points serve a locale, not a currency (1b943b4)
- translate storefront route paths with ->localized() (c2e260e)
- write the catalog through actions that dispatch domain events (88c7d7e)
Bug Fixes
- announce SEO URL changes after commit and keep a replacement chosen after delete (8c661e2)
- catalog: compare translations by value, not by stored JSON (124773d)
- check the Route::seo() controller map when the route is registered (1e28717)
- keep the path of non-SEO storefront routes as sent (34b2d8f)
- order-numbers: stop concurrent checkouts looping on MySQL (fc532f1)
- store SEO paths of any length and number duplicates without a limit (22809ce)
- store SEO URLs by resource type key, with one canonical row per page (421e4f9)
- storefront: leave fallback routes below a fixed path alone (4a28fa3)
Code Refactoring
- add typed IDs and take them in cart add, set and remove (01027d9)
- create carts and orders in an explicit sales context (179de1e)
- move catalog models to Larasell\Larasell\Catalog\Models (7af94bf)
- pass typed IDs across module boundaries (f6450f5)
- remove the model registry (f48676b)
- split the storefront off sales channels (48d0e96)
Upgrade guide
Cart::add(),set()andremove()take typed IDs instead of numbersA bare number meant a product ID in
Cart::add()but a cart line ID inCart::set()andCart::remove(). Passing the wrong one changed an unrelated line or added an unrelated product without any error. These methods now take aProductId,VariantIdorCartItemId, so PHP rejects the mix-up.Who is affected
Apps that pass numbers to these methods. Passing a
ProductorProductVariantworks as before.How to upgrade
1use Larasell\Larasell\Carts\CartItemId; 2use Larasell\Larasell\Catalog\ProductId; 3use Larasell\Larasell\Catalog\VariantId; 4 5// Before 6$cart->add($request->integer('product'), 2); 7$cart->set($request->integer('line'), 3); 8$cart->remove($request->integer('line')); 9 10// After 11$cart->add(ProductId::fromString($request->input('product')), 2); 12$cart->add(VariantId::fromString($request->input('variant'))); 13$cart->set(CartItemId::fromString($request->input('line')), 3); 14$cart->remove(CartItemId::of($item));fromString()throwsLarasell\Larasell\Ids\InvalidIdfor input that is not a positive whole number; validate request input before it reaches the cart.Carts and orders need a sales context
Larasell now has sales channels: the places you sell, such as a storefront, a POS or a marketplace. Carts and orders remember the
SalesContextthey were made in (channel, locale and currency), in the new columnssales_channel_idandlocalenext tocurrency. A channel without a URL, such as a POS, works the same way.A cart or order is created in a context you pass, with
createIn(). It never picks one up from whatever context happens to be current, so a cart made in a command or job cannot silently land in the wrong channel. In a storefront controller, inject the request's context. The migrations create adefaultchannel fromapp.url, the supported locales and the enabled currencies.Who is affected
Apps that create carts or orders themselves. Orders made by
Checkoutare not affected: they take the cart's context.How to upgrade
Run the migrations, then create carts with their context:
1use Larasell\Larasell\Models\Cart; 2use Larasell\Larasell\SalesChannels\SalesContext; 3 4// Before 5$cart = Cart::query()->create(['currency' => Currency::EUR]); 6 7// After, in a storefront controller 8public function store(SalesContext $context) 9{ 10 $cart = Cart::createIn($context); 11} 12 13// After, anywhere else 14$cart = Cart::createIn(SalesContext::for('default', 'en'));Cart::query()->create()without a context now throws aLogicException. Acurrencygiven in the attributes still wins over the context's, e.g.Cart::createIn($context, ['currency' => Currency::EUR]).Catalog models live in
Larasell\Larasell\Catalog\ModelsThe catalog is now a module of its own, so its models moved out of the shared
Larasell\Larasell\Modelsnamespace:| Before | After | | --- | --- | |
Larasell\Larasell\Models\Product|Larasell\Larasell\Catalog\Models\Product| |Larasell\Larasell\Models\ProductVariant|Larasell\Larasell\Catalog\Models\ProductVariant| |Larasell\Larasell\Models\Category|Larasell\Larasell\Catalog\Models\Category| |Larasell\Larasell\Models\ProductAttribute|Larasell\Larasell\Catalog\Models\ProductAttribute| |Larasell\Larasell\Models\ProductAttributeValue|Larasell\Larasell\Catalog\Models\ProductAttributeValue| |Larasell\Larasell\Models\ProductImage|Larasell\Larasell\Catalog\Models\ProductImage| |Larasell\Larasell\Models\Relations\Siblings|Larasell\Larasell\Catalog\Models\Relations\Siblings|Tables and columns are unchanged.
Who is affected
Apps that import any of the classes above. Polymorphic relations that stored one of these class names in a
*_typecolumn also need their rows updated, unless the app uses a morph map.How to upgrade
Update the imports:
1// Before 2use Larasell\Larasell\Models\Category; 3use Larasell\Larasell\Models\Product; 4 5// After 6use Larasell\Larasell\Catalog\Models\Category; 7use Larasell\Larasell\Catalog\Models\Product;This replaces them across an app on Linux (on macOS, use
sed -i ''):1grep -rlE 'Larasell\\Larasell\\Models\\(Product|Category|Relations)' app config database routes tests \ 2 | xargs sed -i -E 's/Larasell\\Larasell\\Models\\(Product|Category|Relations)/Larasell\\Larasell\\Catalog\\Models\\\1/g'Write the catalog through its actions
Products, variants, categories, attributes, attribute values and images are now written through actions in
Larasell\Larasell\Catalog\Actions, such asCreateProduct,UpdateProduct,MoveCategoryandDeleteProduct. Each action runs in its own transaction, locks the rows it changes, checks its rules before writing, and dispatches a domain event fromLarasell\Larasell\Catalog\Events(ProductUpdated,CategoryMoved, …). Other modules, such as SEO, react to those events inside the same transaction, so a product and its URL are saved together or not at all.Writing a model directly no longer updates anything else.
$product->update(),Product::create()and$category->delete()still write the row, but no event is dispatched: SEO URLs are not created, moved, or marked as gone, and the rules other modules set (no delete of a product with open orders or reserved stock) are not checked.Who is affected
Apps, imports and seeders that create, change or delete catalog models with Eloquent. Reading models is unchanged.
How to upgrade
Call the action that matches the change. Actions take IDs and plain input:
1use Larasell\Larasell\Catalog\Actions\CreateCategory; 2use Larasell\Larasell\Catalog\Actions\CreateProduct; 3use Larasell\Larasell\Catalog\Actions\DeleteProduct; 4use Larasell\Larasell\Catalog\Actions\MoveCategory; 5use Larasell\Larasell\Catalog\Actions\SetProductCategories; 6use Larasell\Larasell\Catalog\Actions\UpdateProduct; 7 8// Before 9$product = Product::create(['name' => ['en' => 'Boots'], 'price' => Price::of(1000)]); 10$product->categories()->attach($shoes); 11$product->update(['name' => ['en' => 'Leather Boots']]); 12$boots->update(['parent_id' => $sale->id]); 13$product->delete(); 14 15// After 16$product = app(CreateProduct::class)->handle(['name' => ['en' => 'Boots'], 'price' => Price::of(1000)]); 17app(SetProductCategories::class)->handle($product->id, [$shoes->id]); 18app(UpdateProduct::class)->handle($product->id, ['name' => ['en' => 'Leather Boots']]); 19app(MoveCategory::class)->handle($boots->id, $sale->id); 20app(DeleteProduct::class)->handle($product->id);UpdateCategoryno longer acceptsparent_idorposition; useMoveCategory, which refuses to put a category below itself or one of its descendants (CategoryCycle).DeleteProductthrowsProductInUsewhile an open order contains the product or units of it are reserved.After bulk writes that bypassed the actions, bring the URLs up to date:
1php artisan larasell:regenerate-seo-urlsEntry points serve a locale; the currency comes from the visit
An entry point (a host plus an optional path prefix) now serves one locale, not one locale and one currency. Tying a URL to a currency meant a shop selling in EUR and CHF needed a domain per currency, which search engines see as duplicate pages. A visit starts in the entry point's optional
default_currency, else the channel's default, and can switch to any other currency the channel sells in through the same URL.Who is affected
Apps that call
AddEntryPoint, readEntryPoint::$currencyor thecurrencycolumn oflarasell_storefront_entry_points, or listen toEntryPointCreated. Apps that only useStorefrontContextare not affected.How to upgrade
| Before | After | | --- | --- | |
AddEntryPointinputcurrency|default_currency(optional) | |EntryPoint::$currency, columncurrency|EntryPoint::$defaultCurrency, nullable columndefault_currency| |EntryPointCreated::$currency|EntryPointCreated::$defaultCurrency(nullable) | | one primary entry point per channel, locale and currency | one primary entry point per channel and locale | | a currency needs its own entry point |$storefront->withCurrency(Currency::EUR)on the same entry point |1// Before 2$addEntryPoint->handle($channelId, ['url' => 'https://shop.ch', 'locale' => 'de', 'currency' => 'CHF']); 3 4// After 5$addEntryPoint->handle($channelId, ['url' => 'https://shop.ch', 'locale' => 'de', 'default_currency' => 'CHF']); 6$storefront = $storefront->withCurrency(Currency::EUR); // the visitor picked EURWhere the visitor's pick comes from, such as a switcher or their cart's
currency, is up to the app: callwithCurrency()with it.Larasell's models can no longer be swapped
The
larasell.modelsandlarasell.seo.modelsconfig keys are gone, together withLarasell\Larasell\Models\ModelRegistryandModelEntry. Core code now always uses its own models, such asProductandOrder.Swapping a model let an app change core behaviour from the outside, and every module had to look its models up at runtime. That made identities unstable (a swapped product got new SEO URLs) and made module boundaries impossible to check. Apps now keep their own data in their own models and link them to core records by ID.
Who is affected
Apps that set
larasell.models.*orlarasell.seo.models.seo_urlinconfig/larasell.phporconfig/larasell/seo.php, or that resolveModelRegistryfrom the container. Apps that never swapped a model are not affected; you may delete themodelssection from published config files.How to upgrade
Remove the
modelssection from your published config. Move the fields and relations your subclass added into a model of your own with a foreign key to the core table:1// Before: config/larasell.php 2'models' => [ 3 'product' => App\Models\Product::class, // extends Larasell\Larasell\Catalog\Models\Product 4], 5 6// After: a model of your own, linked by product ID 7namespace App\Models; 8 9use Illuminate\Database\Eloquent\Model; 10use Illuminate\Database\Eloquent\Relations\BelongsTo; 11use Larasell\Larasell\Catalog\Models\Product; 12 13class ProductDetails extends Model 14{ 15 protected $fillable = ['product_id', 'care_instructions']; 16 17 /** @return BelongsTo<Product, $this> */ 18 public function product(): BelongsTo 19 { 20 return $this->belongsTo(Product::class); 21 } 22} 23 24// Read it next to the product 25$details = ProductDetails::query()->where('product_id', $product->id)->first();1// Migration for the app's own table 2Schema::create('product_details', function (Blueprint $table) { 3 $table->id(); 4 $table->foreignId('product_id')->unique()->constrained('larasell_products')->cascadeOnDelete(); 5 $table->text('care_instructions')->nullable(); 6 $table->timestamps(); 7});To react to changes of core records, listen to their domain events, such as
Larasell\Larasell\Catalog\Events\ProductDeleted, instead of overriding model methods.Product and category slugs are replaced by SEO URLs
The
slugcolumns oflarasell_productsandlarasell_categoriesare dropped, together with the routing built on them. Storefront URLs now live in the new SEO module (larasell_seo_urls): each product and category gets a URL per channel and locale, renames keep the old URL as a redirect, and deleted pages answer 410 or redirect to a replacement.Existing slugs are not carried over. After migrating, products live at
p/{name}and categories at{parent path}/{name}, built from their names. Links to old slug URLs answer 404 unless you redirect them yourself.Who is affected
Apps that use any of these:
- the
slugattribute ofProductorCategory, including'slug' => …increate()orupdate()calls and queries such aswhere('slug->en', …); Larasell\Larasell\Routing\ProductDetailRoute,Larasell\Larasell\Routing\ProductListingRoute, orLarasell\Larasell\Routing\CategoryUrl;Larasell\Larasell\Http\Requests\ProductDetailRequest;ProductListingRequestwith a{category}slug path;OrderItem::$product_slug. Checkout no longer writes it; the column stays, so orders placed before the upgrade keep their value.
Attribute and attribute value slugs (
ProductAttribute::$slug,ProductAttributeValue::$slug) are unchanged.How to upgrade
Run the migrations, then create URLs for your existing products and categories:
1php artisan migrate 2php artisan larasell:regenerate-seo-urlsStop passing
slugwhen creating products and categories, and create them through the catalog's actions, which give them their URLs (seecatalog-writes-through-actions.md):1// Before 2Product::create(['slug' => 'boots', 'name' => ['en' => 'Boots'], 'price' => Price::of(1000)]); 3 4// After 5app(CreateProduct::class)->handle(['name' => ['en' => 'Boots'], 'price' => Price::of(1000)]);Register storefront routes inside
Route::storefront(). Typed parameters are resolved from SEO URLs, andRoute::seo()serves categories at any depth and custom URLs:1// Before 2use Larasell\Larasell\Routing\ProductDetailRoute; 3use Larasell\Larasell\Routing\ProductListingRoute; 4 5ProductDetailRoute::get(ShowProduct::class, prefix: 'p'); 6ProductListingRoute::get(ShowCategory::class, prefix: 'c'); 7 8// After 9use Illuminate\Support\Facades\Route; 10use Larasell\Larasell\Catalog\Models\Category; 11use Larasell\Larasell\Catalog\Models\Product; 12 13Route::storefront(function () { 14 Route::get('p/{product}', ShowProduct::class); 15 Route::seo([Category::class => ShowCategory::class]); 16});Controllers that used
ProductDetailRequesttake the product directly.ProductListingRequestkeeps working and reads the category the route bound:1// Before 2public function __invoke(ProductDetailRequest $request) 3{ 4 $product = $request->product(); 5} 6 7// After 8public function __invoke(Product $product) {}Generate links with the
Seofacade instead ofCategoryUrlor slugs:1// Before 2CategoryUrl::make($category); 3url('p/'.$product->slug->get()); 4 5// After 6use Larasell\Larasell\Seo\Facades\Seo; 7 8Seo::url($category); 9Seo::url($product);Deleted and hidden pages throw
SeoPageGone(410) andSeoPageHidden(404). Render them like any exception, e.g. inbootstrap/app.php:1use Larasell\Larasell\Seo\Exceptions\SeoPageGone; 2 3$exceptions->render(fn (SeoPageGone $e) => response()->view('discontinued', $e->snapshot, 410));Absolute URLs need a
StorefrontContextA sales channel no longer has to be a web shop: POS, headless or marketplace channels have no URL. So
SalesContextnow holds only channel, locale and currency, and everything that builds an absolute URL takes aLarasell\Larasell\Storefront\StorefrontContext: a sales context plus the entry point (host and prefix) it is reached through. Entry points, storefront routing andstorefront_route()moved to theLarasell\Larasell\Storefrontnamespace; its service provider is auto-discovered.| Before | After | | --- | --- | |
SalesContext::for('default', 'en')->url($path)|StorefrontContext::for('default', 'en')->url($path)| |$context->entryPointonSalesContext|$storefront->entryPoint,$storefront->salesfor the rest | |storefront_route($name, $params, SalesContext $context)|storefront_route($name, $params, StorefrontContext $context)| |Seo::url($page, SalesContext $context),availableUrl,preload,resolve| the same with aStorefrontContext| |GoneStrategy,HiddenPagePolicy,SeoPageGone::$context,SeoPageHidden::$context| typed withStorefrontContext| |Larasell\Larasell\SalesChannels\Actions\AddEntryPoint,DeleteEntryPoint|Larasell\Larasell\Storefront\Actions\…| |Larasell\Larasell\SalesChannels\Events\EntryPointCreated,EntryPointDeleted|Larasell\Larasell\Storefront\Events\…| |SalesChannelTypeenum | a string type registered withSalesChannelTypes, e.g.'storefront'| |CreateSalesChannelwithentry_points|CreateSalesChannelwithtype, thenAddEntryPointper entry point | | tablelarasell_sales_channel_entry_points, columnentry_point_idon carts and orders | tablelarasell_storefront_entry_points; carts and orders store no entry point |Who is affected
Apps that build URLs with a context they pass themselves, implement a gone strategy or hidden page policy, or create channels and entry points in seeders. Storefront controllers that rely on the current request's context keep working.
How to upgrade
1// Before 2use Larasell\Larasell\SalesChannels\SalesContext; 3 4$url = Seo::url($product, SalesContext::for('default', 'de')); 5$link = storefront_route('order', ['order' => $order->public_id], $order->sales_context); 6 7// After 8use Larasell\Larasell\Storefront\StorefrontContext; 9 10$url = Seo::url($product, StorefrontContext::for('default', 'de')); 11// An order links back through the primary entry point of its channel, locale and currency. 12$link = storefront_route('order', ['order' => $order->public_id], StorefrontContext::for($order->sales_context));To sell through another kind of channel, register its type, e.g. in a service provider:
1$this->callAfterResolving(SalesChannelTypes::class, fn (SalesChannelTypes $types) => $types->register('pos'));Actions, events, jobs and sales contexts use typed IDs
The catalog, sales channel and storefront actions, the workflows, and the jobs that release expired orders now take typed IDs such as
ProductIdorCategoryIdinstead of bare numbers. Their events andSalesContextcarry typed IDs too. A number meant for one kind of record can no longer be passed where another is expected, e.g. a variant ID toDeleteProduct.Each ID class wraps a positive
int(->value) and is named after its record:| Module | IDs | | --- | --- | | Catalog (
Larasell\Larasell\Catalog) |ProductId,VariantId,CategoryId,AttributeId,AttributeValueId,ImageId| | Sales channels (Larasell\Larasell\SalesChannels) |SalesChannelId| | Storefront (Larasell\Larasell\Storefront) |EntryPointId| | Carts (Larasell\Larasell\Carts) |CartId,CartItemId| | Checkout (Larasell\Larasell\Checkout) |OrderId|Models keep their
intkeys; nothing changes inside a module.Who is affected
Apps that call these actions or workflows, listen to their events, construct
SalesContextorEntryPointthemselves, or pass a channel ID toSalesContext::for().How to upgrade
Wrap IDs where you call an action, and read
->valuewhere you need the number:1use Larasell\Larasell\Catalog\Actions\SetProductCategories; 2use Larasell\Larasell\Catalog\Actions\UpdateProduct; 3use Larasell\Larasell\Catalog\CategoryId; 4use Larasell\Larasell\Catalog\ProductId; 5 6// Before 7app(UpdateProduct::class)->handle($product->id, ['sku' => 'B-2']); 8app(SetProductCategories::class)->handle($product->id, [$shoes->id, $sale->id]); 9 10// After 11app(UpdateProduct::class)->handle(ProductId::of($product), ['sku' => 'B-2']); 12app(SetProductCategories::class)->handle(ProductId::of($product), CategoryId::many([$shoes->id, $sale->id])); 13 14// From request input; throws Larasell\Larasell\Ids\InvalidId for anything but a positive whole number 15app(UpdateProduct::class)->handle(ProductId::fromString($request->route('product')), $attributes);In listeners, compare IDs with
equals()or their->value, never with===, which compares instances:1// Before 2if ($event->productId === $product->id) { /* ... */ } 3 4// After 5if ($event->productId->value === $product->id) { /* ... */ } 6if ($event->productId->equals(ProductId::of($product))) { /* ... */ }CreateProductVariantnow returns aVariantIdandGenerateProductVariantsalist<VariantId>.SalesContext::for()andStorefrontContext::for()take a channel key or aSalesChannelId, no longer anint.ReleaseExpiredInventoryForOrderandReleaseExpiredPromotionRedemptionsForOrdertake anOrderId. InCartMerged,$sourceCartIdis aCartId; inCartMergeLineAdjustment,$variantIdis aVariantId.1.23.0 (2026-11-03)
⚠ BREAKING CHANGES
- upgrade Chronicle to 0.4
- require PHP 8.4
Features
- upgrade Chronicle to 0.4 (1aec70d)
Build System
- require PHP 8.4 (bc8bb56)
Upgrade guide
Larasell uses Chronicle 0.4
Larasell's structured exceptions,
LicenseExceptionandUnknownEntitlement, now implement Chronicle 0.4'sLarasell\Chronicle\Exceptions\StructuredExceptioninstead ofLarasell\Chronicle\StructuredError. Chronicle 0.4 bounds and redacts every event it logs and adds a fake for tests, and Larasell needs it so apps and the admin panel can upgrade too.Who is affected
- Apps that catch Larasell's exceptions as
Larasell\Chronicle\StructuredError. - Apps that use Chronicle themselves, e.g. its
LogRequestsmiddleware or structured exceptions. Composer now installs Chronicle 0.4, so follow Chronicle's upgrade guide.
Apps that catch
LicenseExceptionorUnknownEntitlementby name, or only readerrorCode(),errorWhy(),errorFix()anderrorLink(), are not affected.How to upgrade
Catch the new interface:
1// Before 2use Larasell\Chronicle\StructuredError; 3 4try { 5 $license->activate($key); 6} catch (StructuredError $exception) { 7 report($exception); 8} 9 10// After 11use Larasell\Chronicle\Exceptions\StructuredException; 12 13try { 14 $license->activate($key); 15} catch (StructuredException $exception) { 16 report($exception); 17}Larasell requires PHP 8.4
Larasell now needs PHP 8.4 or newer. Chronicle, the logging package Larasell is built on, requires PHP 8.4 from version 0.3.0, and Larasell's CI already runs on PHP 8.4 only, so PHP 8.3 was untested.
Who is affected
Apps that run on PHP 8.3. Composer won't install this version of Larasell there. Apps on PHP 8.4 or 8.5 are not affected.
How to upgrade
Upgrade PHP to 8.4 or newer on every server and in CI, then raise the PHP requirement in your app's
composer.json:1// Before 2"require": { 3 "php": "^8.3" 4} 5 6// After 7"require": { 8 "php": "^8.4" 9}1.22.0 (2026-11-02)
⚠ BREAKING CHANGES
- products no longer allow backorders unless allow_backorders is set to true.
Features
- add a position to categories to define their order (c51fe51)
- disable backorders by default (b0a7fc8)
Upgrade guide
Products no longer allow backorders by default
larasell_products.allow_backordersnow defaults tofalseinstead oftrue, so a product created withoutallow_backorderscan't be sold beyond its stock. Allowing overselling should be an explicit choice, not an accident.Who is affected
Apps that create products without setting
allow_backordersand rely on them accepting orders when stock runs out. Existing products keep their stored value; only products created after running the migration are affected. Apps that always setallow_backordersexplicitly are not affected.How to upgrade
Run the new migration:
1php artisan migrateThen pass
allow_backorderswherever products should still accept backorders:1use Larasell\Larasell\Models\Product; 2use Larasell\Larasell\Price; 3 4// Before 5Product::create([ 6 'slug' => 'gift-card', 7 'name' => 'Gift card', 8 'price' => Price::of(2500), 9]); 10 11// After 12Product::create([ 13 'slug' => 'gift-card', 14 'name' => 'Gift card', 15 'price' => Price::of(2500), 16 'allow_backorders' => true, 17]);1.21.0 (2026-11-02)
⚠ BREAKING CHANGES
- ProductAttribute::name and ProductAttributeValue::name are Translatable instead of string.
- Locale enum values use hyphens instead of underscores, and Translatable falls back to larasell.locale.default instead of app.fallback_locale. A migration renames stored translation keys.
Features
- make product attribute and value names translatable (#145) (d144986)
- use BCP 47 locale tags and configure supported locales (#144) (a1cf928)
Upgrade guide
Product attribute and value names are now
TranslatableProductAttribute::$nameandProductAttributeValue::$nameare nowLarasell\Larasell\Translatableinstances instead of strings, like product and category names. Variant options such as "Color" and "Red" can now be shown in the visitor's language.Who is affected
Code that reads
nameon aProductAttributeorProductAttributeValueand treats it as a string, for example by printing it, comparing it, or callingpluck('name').ProductVariant::options()andProductVariant::name()keep returning strings, now in the current locale. Code that only uses them is not affected. Writing plain strings keeps working: they are stored for the current locale.How to upgrade
Run the migrations. They turn every existing name into a translation for
larasell.locale.default.1php artisan migrateCall
get()where you need the text:1// Before 2$attribute->name; // 'Color' 3$attribute->values()->pluck('name'); 4 5// After 6$attribute->name->get(); // 'Color', or 'Farbe' when the locale is 'de' 7$attribute->values->map(fn ($value) => $value->name->get());Translate names the same way as product names:
1use Larasell\Larasell\Models\ProductAttribute; 2 3ProductAttribute::create(['slug' => 'color', 'name' => ['en' => 'Color', 'de' => 'Farbe']]);Localevalues are now BCP 47 tags (de-DEinstead ofde_DE)Every value of
Larasell\Larasell\Enums\Localethat has a region or script now uses a hyphen, as inde-DE,zh-Hansandes-419. Translation keys are stored the same way. This is the format HTTP headers, HTMLlangandhreflanguse, so no conversion is needed at the HTTP boundary. Script locales likezh-Hans, whichTranslatableused to reject, now work.Who is affected
- Code that stores, compares or hard-codes
Locale->value, or callsLocale::from()with an underscore value such as'de_DE'. - Code that reads translation JSON (
name,slug,description,product_name) directly from the database, or queries it with paths likeslug->de_DE.
Apps that only use base locales such as
enanddeare not affected: their values did not change.App::setLocale('de_DE')keeps working, andTranslatableaccepts underscore keys and stores them as hyphens.How to upgrade
Run the migration. It renames stored translation keys from
de_DEtode-DEand can be rolled back.1php artisan migrateReplace underscore values in your code, or canonicalize input first:
1use Larasell\Larasell\Enums\Locale; 2 3// Before 4Locale::from('de_DE'); 5$query->where('slug->de_DE', $slug); 6 7// After 8Locale::from('de-DE'); 9Locale::from(Locale::canonicalize('de_DE')); // for input in any case or style 10$query->where('slug->de-DE', $slug);Translations fall back to
larasell.locale.default, notapp.fallback_localeWhen a translation is missing in the requested locale,
Translatable::get()now falls back to the shop's default locale from the newconfig/larasell/locale.php. Plain string values read from the database are stored under that locale too. This gives a shop one setting for its default language, next to the list of locales it supports.Who is affected
Apps that set
app.fallback_localeto something other thanen. Apps whoseapp.fallback_localeisenare not affected, becauselarasell.locale.defaultalso defaults toen.How to upgrade
Publish the config and set the default to your previous
app.fallback_locale. The default must also be insupported.1php artisan vendor:publish --tag=larasell-config1// Before: config/app.php 2'fallback_locale' => 'de', 3 4// After: config/larasell/locale.php 5use Larasell\Larasell\Enums\Locale; 6 7return [ 8 'supported' => [Locale::GERMAN, Locale::ENGLISH], 9 'default' => Locale::GERMAN, 10];1.13.0 (2026-09-10)
Features
- add cart to starter kit (9f63ed5)
- add checkout to starter kit (dd7c46a)
- add compare at price (885fa5b)
- add customers (02b49b0)
- add explicit exceptions for promotions (e37cb57)
- add i18n to starter kit (#82) (58ad661)
- add method to fetch visible variants easily (1fc49e0)
- add optional billing address form (a30e093)
- add order history (8ae2e95)
- add order related mails (6cc37b2)
- add products to cart by id (#90) (f283f57)
- add promotion codes to the storefront (f140ab0)
- add styles to starter kit (#105) (e23acfc)
- add weight and dimension (958fe25)
- display variants on starter kit (69a9620)
- get discounted total for a CartItem (5273f1f)
- make starter kit multi lang (#84) (6bec9d1)
- prefill address when ordering with an account (e1ea5fb)
- show discount for each cart item in the starter kit (bb86a6d)
- show tax and shipping on starter kit (af83c69)
Bug Fixes
- display empty state on cart page (8d36bee)
- load option relations inside ProductVariant::options() (df8e205)
- remove all related stuff from the starter kit (#85) (3170dc7)
- remove multi locale stuff from starter kit (#89) (daea92a)
- rename optionSnapshots to options (00e0eff)
- rename snapshotName to name (807d49d)
1.12.0 (2026-09-03)
Features
- add cart exceptions (#81) (542d5e2)
- add footer to starter kit (#80) (3d08574)
- add menu to starter kit (#79) (9234ed3)
- add product listing page to starter kit (#78) (720e6b4)
- add public id to orders (93ec712)
- add starter kit (#76) (60cb6db)
Bug Fixes
- format prices on order confirmation page on the server (af604fc)
1.4.0 (2026-08-30)
Features
- add discount events (45a8435)
- add priority and exclusivity for promotions (9897892)
- add promotion api (#34) (55d1e7a)
- add promotion effects (#36) (2d16d25)
- add promotion limits (#46) (1c42670)
- add promotions (#33) (d486184)
- add user entered promotion codes (#39) (66f9c95)
- calculate promotions (#37) (7919935)
- snapshot discounts (#38) (f2036f4)
1.3.0 (2026-08-29)
Features
- add expired inventory cleanup command (76aa531)
- add inventory events (2587b56)
- add inventory reservation lifecycle events (3ee7648)
- add optional company details to addresses (#19) (2114346)
- add optional company details to addresses (#22) (18f87af)
- add optional order cancelation reason (1fa86da)
- configure inventory reservation expiration (#26) (0010f53)
- consume inventory reservations on payment (9f4db0b)
- release expired inventory reservations (fd94539)
- release inventory reservations on cancellation (d814741)
- reserve inventory during checkout (b8a2323)
Bug Fixes