tomatophp/tomato-products-module
Products module for TomatoPHP adds complete product management to your Laravel admin panel—create, organize, and publish products with categories, pricing, inventory, images, and variants. Integrates smoothly with Filament and the TomatoPHP ecosystem.
Assume the host, not the module, is the starting point. This package is a Filament-flavoured admin layer inside the TomatoPHP module ecosystem. Before you pull it in, confirm your app already has:
tomatophp/* core) in place — this module assumes conventions from its siblings rather than bootstrapping a standalone admin.Install and expose the moving parts:
composer require tomatophp/tomato-products-module
# the module ships config, migrations, lang and (usually) views
php artisan vendor:publish --tag=tomato-products-module-config
php artisan vendor:publish --tag=tomato-products-module-migrations
php artisan vendor:publish --tag=tomato-products-module-lang
php artisan migrate
List the available tags first (php artisan vendor:publish with no arguments) — tag names are the single most common source of "why is nothing published" confusion in this family of packages.
Where to look first, in order:
composer.json → the require block. This tells you the exact Filament major, the sibling tomatophp/* packages it drags in (attributes, categories, media, translations), and therefore how much of your app this module is quietly coupled to.src/Models/ → the Product model and, critically, the variation/price/stock models. This is the real value of the package; read the relationships and column types before writing a single line of your own code. Confirm the namespace (Tomato\... vs TomatoPHP\...) here rather than guessing.database/migrations/ → the actual schema. Names, nullability, and which table owns price and stock decide everything downstream.src/Filament/Resources/ → how the admin is wired, what the form/table schemas look like, and where you'll hang your extension points.config/ and lang/.First use case — get one product end-to-end before customising anything. Create a product in the admin panel with two variations, a price and a stock figure on each, then verify from the CLI what actually landed in the database:
php artisan tinker
>>> $p = \Tomato\Products\Models\Product::with('variations')->latest()->first();
>>> $p->toArray();
>>> $p->variations->map(fn ($v) => [$v->sku ?? $v->id, $v->price, $v->stock]);
If that round-trip works, you understand the data model well enough to build on it. If variations isn't the relationship name, you just learned the most important thing about this package for the price of one command.
The admin UI is the module's product. Your storefront, API, or downstream workflow should not reach into Filament resources — it should go through a thin read layer you own:
namespace App\Catalog;
use Tomato\Products\Models\Product;
class ProductRepository
{
public function publishedWithPrices(int $perPage = 24)
{
return Product::query()
->where('is_active', true) // confirm the actual flag name in migrations
->with(['variations', 'categories'])
->orderByDesc('created_at')
->paginate($perPage);
}
public function minPriceFor(Product $product): float
{
return (float) $product->variations->min('price');
}
}
Wrapping the vendor model now costs an hour and buys you the ability to swap, fork, or replace the module later without touching controllers. Given a v1.0.x, single-maintainer package, this is not optional hygiene.
In every schema this package family ships, the product is the marketing record (name, description, media, category) and the variation is the thing you actually sell. Day-to-day consequences:
min('price')) or maintain it as a denormalised cache you can rebuild.stock on a variation is the inventory unit.The module gives you a stock column. It almost certainly does not give you concurrency-safe reservation. Do not wire an order-placed hook straight to $variation->decrement('stock'). Put it in a service with a lock:
namespace App\Catalog;
use Illuminate\Support\Facades\DB;
use Tomato\Products\Models\ProductVariation;
class StockService
{
public function reserve(int $variationId, int $qty): bool
{
return DB::transaction(function () use ($variationId, $qty) {
$variation = ProductVariation::whereKey($variationId)
->lockForUpdate()
->firstOrFail();
if ($variation->stock < $qty) {
return false;
}
$variation->decrement('stock', $qty);
return true;
});
}
}
This is the single highest-value piece of code you will write on top of this package. Budget for it in the spike.
Add columns and fields from your own panel provider or a dedicated CatalogServiceProvider, using Filament's global configuration hooks and subclasses:
use Filament\Tables\Table;
use Tomato\Products\Filament\Resources\ProductResource;
// global tweak, applies everywhere
Table::configureUsing(function (Table $table) {
$table->defaultSort('created_at', 'desc');
});
// or a project-local resource that inherits the module's
class ProductResource extends \Tomato\Products\Filament\Resources\ProductResource
{
public static function getNavigationGroup(): ?string
{
return 'Catalogue';
}
public static function table(\Filament\Tables\Table $table): \Filament\Tables\Table
{
return parent::table($table)->columns([
...parent::table($table)->getColumns(),
// your extra columns
]);
}
}
Then unregister the vendor resource from the panel and register yours. This survives composer update; editing vendor/ does not.
Need a barcode or a cost_price? Write database/migrations/xxxx_add_barcode_to_products.php against the module's table. Do not edit the published module migration — a fresh install of the module on another environment will reintroduce the pristine schema and your column will silently vanish from CI.
Publish lang/ and override strings in lang/vendor/tomato-products-module/{locale}/. The upstream release history here is translation-only commits, so package-level lang files are actively churning; treat anything you write inside the package directory as disposable.
If you need team-scoped products, pair with tomato-roles / tomato-permissions and register your own policies in AuthServiceProvider rather than assuming defaults. For tenancy, add a team_id (or your tenant key) via your own migration plus a global scope on a subclass — the package won't ship awareness of your tenancy model.
If you need JSON endpoints, write your own resource controllers and JsonResource classes over the module's models. Likewise ship your own factories and seeders, resolving the model through the container (app(\Tomato\Products\Models\Product::class)) so a namespace change is a one-line fix rather than a grep across the codebase.
RefreshDatabase + the module's published migrations gives you a real schema in tests. Build two or three factories (product, variation, category) on day one — every subsequent test and seeder depends on them, and they double as executable documentation of what you believe the schema is.
Pin the exact tag. This is one module in a large tomatophp/* monorepo. A composer update on any sibling can drift the whole set — including Filament. Use "tomatophp/tomato-products-module": "1.0.2" (or a ~1.0.2 at most) and upgrade deliberately, reading composer.json diffs.
Filament major bumps break resource registration first. TomatoPHP modules track Filament 3.x. Before upgrading Filament in the host app, diff the module's require block and boot the panel on a branch. Expect the failure to surface as a blank or missing resource, not a clear exception.
"The resource doesn't appear in the panel" — checklist, in order:
bootstrap/providers.php, config/app.php, or the module's module.json)?enabled / navigation flag on after publishing?canViewAny() return true for the logged-in user? A missing policy returning false hides resources silently in Filament.Releases here are thin and often translation-only. v1.0.2 is literally "update translation". Before pulling, git diff v1.0.1..v1.0.2 — the changelog note is not a reliable scope signal, and translation-only patches are the safe ones. Conversely, never infer capability from a version bump; nothing in a lang commit implies stock, pricing rules, or checkout behaviour.
Migrations are additive and idempotent-by-fresh-install. Renaming a vendor column is a trap: the module reintroduces its own schema on fresh installs and in CI. Add columns, add indexes, add your own tables — don't mutate theirs.
Stock is a number, not an inventory system. There is no reservation, no backorder semantics, no multi-warehouse support in a "products module" of this size. If your roadmap needs any of that, this package is the wrong layer — write the inventory domain yourself alongside it.
Don't hard-code strings the package translates. If you find yourself writing labels inside a vendor resource, you are one composer update away from losing them. Override via the vendor lang path.
Debugging toolkit that actually pays off here: php artisan tinker against the models (fastest way to confirm relationship names and column reality), php artisan about for provider/panel state, Laravel Telescope for the query log while exercising the admin, and a throwaway test that hits the resource route to confirm policies.
Extension points, ranked by durability: your own migrations and subclasses (safe) → published config/lang/views (safe-ish, re-publish on upgrade) → Filament configureUsing hooks (safe) → model observers and repositories (safe) → editing vendor/ (never). The patch cadence on this package is fast enough that vendor edits are effectively temporary.
Escape hatch, defined before you adopt. Because the dependency is v1.0.x with one apparent maintainer, decide up front how you'd carry a fork: which of your files reference the vendor namespace, how many of them there are, and what the composer.json repositories VCS entry would look like. If that list is short — which it will be if you followed the repository/service pattern above — the risk is manageable. If it's long, stop and build the schema yourself; you'll spend less time than you'd spend adapting this one.
How can I help you explore Laravel packages today?