Stores
Overview
A Store represents a retailer domain (e.g., Amazon, eBay) and holds all configuration for how to scrape product data from that retailer's pages. Stores are the central configuration entity — every URL belongs to a store, and the store's scrape strategy determines how data is extracted.
Database Schema
Table: stores
| Column | Type | Description |
|---|---|---|
id |
int | Primary key |
name |
string | Display name (e.g., "Amazon US") |
slug |
string | Auto-generated from name via Spatie HasSlug |
initials |
string, nullable | 2-char code, auto-generated from name if empty |
domains |
json | Array of {"domain": "example.com"} objects |
scrape_strategy |
json | Extraction rules per field (title, price, image, availability) |
settings |
json | Scraper service, locale, currency, test URL, API options |
notes |
text, nullable | Rich text notes |
cookies |
text, nullable | HTTP cookies string (cookie1=value; cookie2=value) |
user_id |
foreign, nullable | Owner user |
timestamps |
created_at, updated_at |
Model: App\Models\Store
Relationships
user()— BelongsToUserurls()— HasManyUrlproducts()— HasManyThroughProduct(viaUrl)
Key Accessors
initials— Auto-generates 2-letter code from name ("Example Store" -> "ES", "Amazon" -> "AM")domains_html— Comma-separated domains as HtmlStringscraper_service— Fromsettings.scraper_service, defaults tohttpscraper_options— Parsessettings.scraper_service_settingsnewline-separatedkey=valuepairs into arraytest_url— Fromsettings.test_urllocale— Fromsettings.locale_settings.locale, falls back toCurrencyHelper::getLocale()currency— Fromsettings.locale_settings.currency, falls back toCurrencyHelper::getCurrency()
Domain Matching
The scopeDomainFilter(Builder $query, string|array $domains) scope filters stores by domain using whereJsonContains against the domains JSON column. It accepts a single domain string or array.
hasDomain($domain) is an instance method that checks if a store contains a specific domain.
Important: ScrapeUrl::getStore() resolves a store by extracting the URL host via Uri::of($url)->host() and querying with domainFilter($host)->oldest()->first(). The oldest() ensures deterministic results when multiple stores share a domain.
Deletion Cascade
When a store is deleted (via model booted event):
- All affected products are collected first
- URLs are deleted individually (triggers URL model events which cascade to prices)
- All affected product price caches are updated
Store JSON Structure
domains Column
[
{"domain": "example.com"},
{"domain": "www.example.com"}
]
Always register both www and non-www variants.
scrape_strategy Column
Each field (title, price, image, availability) has a strategy entry. See the url-scraping skill for complete strategy type documentation.
{
"title": {"type": "selector", "value": "meta[property=og:title]|content"},
"price": {"type": "selector", "value": ".a-price > .a-offscreen"},
"image": {"type": "regex", "value": "~\"hiRes\":\"(.+?)\"~"},
"availability": {
"type": "selector",
"value": ".stock-label",
"match": {
"default": "in_stock",
"out_of_stock": {"type": "match", "value": "Out of Stock"},
"pre_order": {"type": "regex", "value": "pre.?order"}
}
}
}
settings Column
{
"scraper_service": "http",
"scraper_service_settings": "device=Desktop Firefox\nsleep=1000",
"test_url": "https://example.com/product",
"locale_settings": {
"locale": "en_US",
"currency": "USD"
}
}
scraper_service—http(curl, fast) orapi(browser-based, JS rendering)scraper_service_settings— Only used for API scraper. Newline-separatedkey=valueoptions passed to seleniumbasetest_url— Saved when testing a store via the Test pagelocale_settings— Used byCurrencyHelperfor price parsing and formatting
Auto Store Creation
AutoCreateStore (app/Services/AutoCreateStore.php) automatically creates a store when a URL is submitted for an unrecognized domain.
Flow
createStoreFromUrl($url)extracts the host and checks for an existing store viadomainFilter- If no store exists, it fetches the page HTML and runs
strategyParse() strategyParse()attempts extraction for title, price, and image in priority order:- Schema.org JSON-LD (
@type: Product) — checked first, most reliable - CSS selectors from
config/price_buddy.phpauto_create_store_strategies - Regex patterns from the same config
- Schema.org JSON-LD (
- If title and price extraction succeed,
getStoreAttributes()builds the store data - The
CreateStoreActionaction creates the store with locale/currency defaults
Domain Handling During Auto-Creation
$host = strtolower(Uri::of($this->url)->host());
if (str_starts_with($host, 'www.')) {
$host = substr($host, 4);
}
// Creates both variants
$attributes['domains'] = [
['domain' => $host],
['domain' => 'www.'.$host],
];
The store name is set to ucfirst($host) (e.g., "Example.com").
Auto-Detection Config
config/price_buddy.php defines the fallback selectors and regex patterns. Key price selectors include:
meta[property="product:price:amount"]|contentmeta[property="og:price:amount"]|content.a-price .a-offscreen(Amazon)[itemProp="price"]|content.price,.product-price,[class*="price"]
Filament Admin UI
Resource: StoreResource
Location: app/Filament/Resources/StoreResource.php
Pages
| Page | Class | Purpose |
|---|---|---|
| List | StoreResource/Pages/ListStores.php |
Store listing with product count, scraper badge |
| Create | StoreResource/Pages/CreateStore.php |
New store form with "Create & test" option |
| Edit | StoreResource/Pages/EditStore.php |
Edit form with Share, Test, Delete actions |
| Test | StoreResource/Pages/TestStore.php |
Test scrape with URL, shows results widget |
Form Sections
- Basics — Name (required)
- Domains — Repeater of domain entries
- Scrape Strategy — Per-field strategy inputs (title, price, image, availability) built via
HasScraperTrait::makeStrategyInput(). Each has type select, value input, prepend/append fields. Schema.org type hides value/prepend/append - Availability Match — Dynamic form groups for each non-InStock
StockStatuscase. Each has match type (exact/regex) and value. First match wins, with configurable default fallback - Scraper Service — HTTP vs API radio with API-only settings textarea
- Locale — Locale and currency selects
- Cookies — Cookie string input
- Notes — RichEditor
HasScraperTrait
app/Filament/Concerns/HasScraperTrait.php provides:
makeStrategyInput($key, $default, $required)— Creates the type/value/prepend/append field groupgetScraperSettings()— Creates the scraper service section
Actions
- ImportStoreAction — Modal with JSON textarea, validates with
ImportStorerule, creates viaCreateStoreAction. Keyboard shortcut:mod+i - ShareStoreAction — Modal displaying JSON export (name, slug, domains, scrape_strategy, settings) with copy-to-clipboard
- TestAfterEdit trait — Adds "Save & test" button that redirects to TestStore page after saving
Test Store Page
- Enter a product URL and click "Test url scrape"
ScrapeUrl::new($url)->scrape(['store' => $store, 'use_cache' => false])runs with cache bypass- Test URL is saved to
settings.test_url TestResultsWidgetdisplays scraped title, price, image, availability with stock status resolution details
Validation Rules
StoreUrl (app/Rules/StoreUrl.php)
Used when adding URLs to products. Validates that:
- The URL's domain belongs to an existing store, OR
- A store can be auto-created from the URL (
AutoCreateStore::canAutoCreateFromUrl()) - The scrape returns a valid title and price (unless product is unavailable)
ImportStore (app/Rules/ImportStore.php)
Validates JSON import structure requires:
name— Store namedomains— Array of domain objectsscrape_strategy.title— Title strategy with type and valuescrape_strategy.price— Price strategy with type and valuesettings.scraper_service— ValidScraperServiceenum value
Factory & Seeding
StoreFactory (database/factories/StoreFactory.php)
Defaults:
- Random name, domains
[['domain' => 'example.com']] - Selector strategies using
og:title,og:price:amount,og:imagemeta tags - HTTP scraper service
States:
forUrl(string $url)— Generates name and domains from a URL's hostname
StoreSeeder (database/seeders/StoreSeeder.php)
Loads country-specific store data from database/seeders/Stores/:
usa.php— Amazon US, eBay USaustralia.php— 15 stores including Amazon AU, JB Hi-Fi, Good Guys, BWS, etc.
Each seeder entry includes real-world scrape strategies for that retailer.
Testing
Test Files
| File | Coverage |
|---|---|
tests/Feature/Filament/StoreTest.php |
Admin CRUD (list, create, edit forms) |
tests/Feature/Api/StoreApiTest.php |
API endpoints (15 tests: CRUD, auth, filtering, sorting, relationships) |
tests/Feature/Models/StoreTest.php |
Model behavior (initials, domains, scraper options, deletion cascade) |
tests/Unit/Rules/ImportStoreTest.php |
Import JSON validation |
tests/Unit/Services/AutoCreateStoreTest.php |
Auto-detection from HTML (meta tags, selectors, regex, schema.org) |
Testing Patterns
// Create store with factory
$store = Store::factory()->create(['name' => 'Test Store']);
// Create store for a specific URL
$store = Store::factory()->forUrl('https://example.com/product')->create();
// Test domain matching
Store::query()->domainFilter('example.com')->first();
// Test auto-creation from HTML
AutoCreateStore::new($url, $html)->strategyParse();
// Test Filament form in Livewire test
livewire(EditStore::class, ['record' => $store->id])
->fillForm([...])
->call('save')
->assertNotified();
Debugging Stores
Common Issues
Store not found for a URL — Check domain registration. The host extracted from the URL must exactly match a domain entry in the store's domains JSON. Test with ScrapeUrl::new($url)->getStore().
Auto-creation fails — AutoCreateStore requires both title and price extraction to succeed. Test with AutoCreateStore::new($url)->strategyParse() to see what was detected. If the page needs JavaScript, auto-creation uses HTTP by default and may fail.
Wrong store matched — getStore() uses oldest()->first(), so the earliest-created store with a matching domain wins. Check for duplicate domain entries across stores.
Scraper options not applied — The scraper_service_settings field must be newline-separated key=value format. The accessor scraper_options parses these. Invalid entries (missing =) are silently filtered out.
Locale/currency issues — If prices display incorrectly, check settings.locale_settings on the store. CurrencyHelper::toFloat() uses locale-aware parsing. A store with en_US locale parses 1,234.56 differently than de_DE which expects 1.234,56.
Tinker Debugging
// Inspect a store's full config
$store = Store::find(1);
dd($store->scrape_strategy, $store->settings, $store->scraper_options);
// Test domain resolution
Store::query()->domainFilter('www.amazon.com')->get();
// Test auto-detection on a URL
$auto = AutoCreateStore::new('https://example.com/product');
dd($auto->strategyParse(), $auto->getStoreAttributes());
// Full scrape test with a specific store
ScrapeUrl::new('https://example.com/product')->scrape(['store' => $store]);
Additional Resources
references/store-data-examples.md— Real-world store configuration examples from seedersurl-scrapingskill — Complete documentation on scrape strategy types, selector syntax, and the scraping pipeline