Filament Admin Panel
Overview
Filament is the admin panel for easy-otc-api — the back office where the OTC One team manages everything that powers the storefront: products, categories, members, orders, carriers, manufacturers, promotions, and so on. It lives at /admin on the API host (e.g. https://api.example.com/admin) and has its own login screen. Access is gated by role: you need the OTC_ONE_ADMIN role to even see the panel. Carrier admins get a narrower, read-only-ish view of products scoped to their own carrier. Everything below assumes you're signed in as an OTC One Admin.
What you can manage
Every item below is a top-level resource in the admin sidebar.
- Products — the storefront catalog. Create, edit, attach images, set pricing, packaging, identifiers, regulatory info, categories, tags.
- Categories — top-level product groupings (e.g. "Pain Relief"). Has its own image and slug.
- Sub-Categories — second-level groupings nested under a category (e.g. "Headache Relief" under "Pain Relief").
- Members — end-customer accounts that shop on the storefront.
- Orders — placed orders, with status, totals, and links to the member who placed them.
- Order Items — individual line items inside an order (one row per product per order).
- Carriers — the wholesale/distribution partners who carry products. Each carrier admin is scoped to their own.
- Manufacturers — the brand/manufacturer behind a product (Perrigo, etc.). Required for the import flow.
- Promotions — promotional campaigns / discount programs.
- Discounts (
ProductDiscountResource) — per-product discount records. - Subscriptions — Subscribe & Save subscriptions members have signed up for.
- Transactions — payment transaction records tied to orders.
- Carts — abandoned and in-progress shopping carts.
- Cart Items — line items inside a cart.
- Cart Logs — audit log of cart activity.
- Activities — generic audit/activity feed (who did what, when).
- Mailing Lists — newsletter / list signups.
- Messages — contact form / support messages from the storefront.
- Agents — staff users (admin-side accounts) with their own roles and permissions.
- Agent Login Logs — login history for staff users.
- Impersonation Logs — record of when an admin impersonated another account.
- Member Login Logs — login history for storefront members.
- Purses — the tag store (Spatie Tags). "Purses" is the local label for the tag taxonomy used to make products searchable.
- User Activity Metrics — aggregated activity counts per user.
Heads-up: tags appear under Purses in the sidebar, but in code / forms they are still "tags" (Spatie's
tagstable, surfaced viaSpatieTagsInput).
How to add a product
This is the section to bookmark. Adding a product correctly is the difference between "it's in the database" and "it actually shows up on the storefront."
1. Open the form
- Go to
/admin/products. - Click New Product (top right). The button only appears if you have OTC One Admin role.
The form is organized into tabs across the top. The tab you're on is saved in the URL, so refreshing won't lose your place. You can fill the tabs in any order — nothing is saved until you click Create at the bottom.
2. Walk the tabs
Overview (required)
- Product Name — required, max 100 chars.
- SKU — required, unique, max 50 chars. This is the canonical product ID; treat it as immutable once set.
- Description — required, rich text. Renders on the product page.
- Active Ingredient — optional. Leave blank for non-drug products.
- Strength — optional (e.g. "500mg").
- Product Images — multi-file upload, up to 10 images, PNG/JPG/WebP, max 5 MB each. The first image becomes the primary, the rest are shown as additional shots. Drag to reorder. Resized variants (thumb / medium / large) are generated by a queue job after save, not synchronously.
Availability
- Available for Purchase (toggle, default on) — whether customers can buy it.
- Requires Prescription (toggle) — flags as Rx.
- Subscribe & Save Eligible (toggle) — whether it can be put on subscription.
- Inventory Count — integer, optional.
- Expiry Date — date, optional.
Pricing (required)
All amounts are in cents. So $10.00 = 1000.
- Price — required.
- Unit Price — optional.
- Case Price — optional.
Packaging (required)
Required fields: Quantity, Quantity Label (e.g. "tablets", "ml"), Inner Pack, Case Pack, Cases / Pallet. The rest (length, width, height, cube, case weight, item dimensions) are optional but recommended for fulfillment.
Identifiers
- UPC — required.
- Case UPC, Inner Pack UPC, NB UPC, NDC 10, NDC 11 — all optional.
Regulatory
- Country of Origin — required.
- Optional: NBE Compare To (brand-name equivalent like "Tylenol"), Regulatory Classification, Added to Product Line, and toggles for Medical Device, SDS Required, Contains FD&C Red Dye #3.
Categories
This tab is critical for visibility (see below).
- Categories — multi-select, searchable. Attach at least one.
- Sub-Categories — multi-select, searchable. You can create a new sub-category inline from this field (it'll firstOrCreate scoped to a parent category).
- Purses (tags) — free-form tag input with suggestions from the existing tag pool. Attach at least one.
Related
- Frequently Bought Together — multi-select of other available products. Drives the "people also bought" module on the storefront.
3. Image upload behavior, in plain English
- It's a multi-file picker. Drag in several at once.
- The first file (top-left in the grid) becomes the primary image — that's what shows in product cards, search results, and as the hero on the product page.
- After you click Create/Save, the system dispatches a
GenerateImageVariantsJobfor each uploaded path. Resized variants are produced asynchronously by the queue worker. - If the queue worker isn't running, the images themselves still upload to S3 and show up — but their resized variants won't exist yet. See "Common gotchas."
4. CRITICAL: what makes a product appear on the storefront
This is the #1 source of confusion. Read it twice.
A product is only included in storefront search results when Laravel Scout indexes it into Meilisearch. That only happens when Product::shouldBeSearchable() returns true. Here is the actual method, verbatim:
public function shouldBeSearchable(): bool
{
return count($this->modelTags()) > 0
&& $this->categories()->exists();
}In plain English, both of these must be true:
- The product has at least one tag (Purse) attached.
- The product has at least one category attached.
If either of those is missing, the product saves fine in the database — but it will never appear in storefront search, never appear in category listings driven by search, and never appear in the product index. It's invisible to customers.
Strongly recommended (third practical requirement): also set a
manufacturer_id. The CSV importer always assigns one (defaulting to "Perrigo" if none is provided), the storefront product page expects one, and several downstream features (manufacturer filtering, related-products, fulfillment routing) rely on it. A product with no manufacturer is technically searchable today if it has tags + a category, but it's a half-configured record that will bite you later.
Pre-publish checklist — say it out loud before you hit Create:
- [ ] At least one Purse / tag is attached.
- [ ] At least one Category is attached.
- [ ] A Manufacturer is set.
Miss any of those and the product won't appear on the storefront. Tags + Categories + Manufacturer. Every time.
5. Save, queue, live
- Click Create (or Save on edit).
- The product row is written. Image upload paths are detached from the form data and processed by
SyncsPrimaryProductImage, which:- Writes
product_imagesrows (first one =is_primary = true). - Dispatches a
GenerateImageVariantsJobper image.
- Writes
- Because the model uses Laravel Scout, saving triggers
shouldBeSearchable(). If it returnstrue, the product is indexed into Meilisearch. - Once the queue worker drains the image variant jobs, sized image variants are available on the CDN.
- The product is live on the storefront.
If the product doesn't appear on the storefront within a minute or two, the cause is almost always one of: missing tag, missing category, or the queue worker isn't running.
How to add a category
- Go to
/admin/categoriesand click New Category. - Fill in the form:
- Category Name — required, max 50 chars. As you type, the slug field auto-fills with a slugified version (only on create — editing the name later does not rewrite the slug).
- Slug — required, unique, URL-friendly. You can override the auto-filled value if you want.
- Display Order — integer, default
0. Lower numbers appear first in storefront category lists. - Description — optional, max 255 chars.
- Category Image — single image upload to S3 (PNG/JPG/WebP, max 5 MB). Variants are generated by
GenerateImageVariantsJobafter save, same as products. - Metadata (collapsed under "Advanced") — free-form key/value pairs for ad-hoc data.
- Click Create.
What happens after you save a category
Two things, both in CreateCategory::afterCreate():
If a category image was uploaded, a
GenerateImageVariantsJobis dispatched so the resized variants get built on the queue.A full product re-index is queued:
phpArtisan::queue('app:index-models', ['model' => 'Product']);This re-runs
shouldBeSearchable()against every product so the new category is reflected in search results. It runs on the queue, so it takes a few seconds to a couple of minutes depending on catalog size. Same thing happens when you edit a category.
Sub-categories work identically. Same form pattern (/admin/sub-categories), same image-variant dispatch, same product re-index in CreateSubCategory::afterCreate(). The only extra field is Parent Category, which is required.
Bulk product import (CSV)
For more than a handful of products, use the CSV importer instead of the form.
Through the admin UI
- Go to
/admin/products. - Click Import Products (header button, only visible to OTC One Admin).
- Upload a CSV. Map the columns to the importer fields. Submit.
The importer (App\Filament\Imports\ProductImporter) processes in chunks of 10 rows with a 5-minute timeout per chunk. You'll get an in-app notification when it finishes, with a count of successful and failed rows.
Headers the importer understands
The required ones (marked requiredMapping() in the importer):
SKU— primary key. The importer doesProduct::firstOrNew(['sku' => ...]), so an existing SKU updates that row; a new SKU creates one.Product Name— required, max 255.UPC— required.Active Ingredient— required.NBE Compare To— required.Quantity— required (the importer splits "30 tablets" into quantity = 30, label = "tablets").Inventory Count— required (null / 0 gets randomized to 20–200; intentional behavior for staging data).
Other headers it handles: Product Description, NDC 10, NDC 11, Price, Case Price, Unit Price (all in dollars in the CSV — converted to cents on import), Is Available (the string "out of stock" maps to false), Category, Sub-Category, packaging dimensions (Length, Width, Height, Cube, Case Weight, Item Dimensions, Inner Pack, Case Pack, Cases Per Pallet), more UPC variants, Country of Origin, SDS Required, Added to Product Line, Contains FD&C Red Dye #3, Regulatory Classification, Medical Device, and Supplier.
Special behaviors during import
- Category and Sub-Category are
firstOrCreated by name. If "Pain Relief" doesn't exist, it gets created on the fly (slug auto-generated from the name). - Supplier maps to Manufacturer — also
firstOrCreated by name. If no supplier is provided, the importer defaults to "Perrigo", so every imported product ends up with amanufacturer_id. - Every imported product is auto-tagged with
otc(so the tag + category invariant is satisfied out of the box). - The first product image is created at the conventional path
s3://.../images/products/{SKU}_01.png— the file itself must exist at that path or the image record will point at a 404. - After all the side-effects run, the importer explicitly calls
$product->searchable()to force the Meilisearch index update.
Targeted column updates from CSV (artisan)
For partial updates — say, you just want to refresh prices or descriptions on existing products — use the artisan command instead of a full import:
php artisan products:update-from-csv {file?} --columns=name,price,description --dry-runfile— path to the CSV (defaults tostorage/csv/).--columns— comma-separated list of fields to update. Only these columns are touched; everything else on the product is left alone.--dry-run— preview what would change without writing to the DB.
It uses the same header normalization map as the importer (so "SRP (Price)", "Price", and "price" all map to the price field), and it updates the right backing table for each column (e.g. price goes to product_prices, category to categorizables, etc.).
Common gotchas
- Tags + Categories + Manufacturer = product visibility. A product without a tag (Purse) and a category will not appear on the storefront —
shouldBeSearchable()returnsfalseand Scout skips it. Also set a manufacturer; the importer does this automatically, the manual form does not, and several downstream features assume it's there. - Image variants are generated on the queue. If the queue worker isn't running, your uploaded images will show at their original size on the storefront, but the sized variants (thumb / card / hero) won't exist yet. Symptoms: missing thumbnails, broken
srcsetimages, slow product pages. Fix: make surephp artisan queue:work(or the production equivalent) is running and draining jobs. - Renaming a product does NOT regenerate its slug. The
HasSlugtrait is configured with->preventOverwrite(), which means the slug is set once on first save and never rewritten — even if you change the name. If you need a new slug, clear the field manually in the database (or via tinker) and re-save the product. This is intentional: it prevents URL breakage for already-indexed products. - Category / sub-category edits trigger a full product re-index. Expect a short delay before search reflects the change. If it doesn't update at all, check that the
app:index-modelsjob actually ran (it's queued, not synchronous). - Prices are stored in cents everywhere in the admin form (
1000= $10.00), but the CSV importer takes dollars and converts. Don't mix the two up. - SKU is the import primary key. Re-uploading a CSV with the same SKUs will update those products in place, not create duplicates. Use this to your advantage for bulk corrections.