Listings Domain
Overview
The Listings domain manages individual service listings in the Wedissimo marketplace. While Vendors represent the service providers, Listings represent the specific services they offer.
Module Location: modules/Listing/Status: ✅ Fully migrated to modular architecture
Domain Responsibilities
- Service listing creation and management
- Pricing and package information
- Listing status and visibility control
- Featured listings promotion
- Listing categories and tagging
- Media gallery relationships (managing order and collection via polymorphic pivot)
- Search integration
- Listing analytics and views
Note: Media files themselves are managed by the core Media model (app/Models/Media.php). A dedicated Media domain module is planned for the future. The Listing module only manages the relationship between listings and media (order, collection) via the polymorphic mediables pivot table.
Core Entities
Listing
Primary Entity: Represents a specific service offering from a vendor
Key Attributes:
id(UUID)vendor_id(FK to Vendor)title,slugdescription,excerptprice(base price),price_extras_description,max_hours,currencylocation,based_inlatitude,longitude,service_center(PostGIS geography point)service_radius_miles,travel_included_milescoverage_min_lat,coverage_min_lng,coverage_max_lat,coverage_max_lng(legacy)status(draft, pending, published, archived)verified(boolean)featured(boolean)view_count,rating,review_countyears_experiencefeatured_image_idpackage_inclusions(JSON)external_reviews(JSON)- SEO fields:
meta_title,meta_description,focus_keyword,canonical_url - Open Graph fields:
og_title,og_description,og_image_url - Twitter Card fields:
twitter_title,twitter_description,twitter_image_url - Booking configuration:
calendar_sync_url,max_booking_days,min_booking_days,booking_lead_time,booking_window_days,requires_approval published_at,created_at,updated_at,deleted_atwp_id(WordPress migration tracking)
Model Location: modules/Listing/Models/Listing.php
Listing Status
Value object representing listing visibility:
draft- Being created, not yet publishedpending- Awaiting approvalpublished- Live and visible in marketplacearchived- Permanently removed from search
Domain Services
ListingService
Location: modules/Listing/Services/ListingService.php
Responsibilities:
- Multi-strategy listing search (Typesense, PostGIS, Eloquent)
- Geographic filtering with PostGIS spatial queries
- Google Places API integration for region-based searches
- Listing retrieval by ID or slug
- Search region metadata for frontend mapping
Key Methods:
class ListingService
{
// Multi-strategy search with geographic filtering
public function search(array $filters, int $perPage = 15): LengthAwarePaginator;
// Get search region metadata (for map visualization)
public function getLastSearchRegion(): ?array;
// Retrieve listings
public function getById(string $id, bool $publishedOnly = true): ?Listing;
public function getBySlug(string $slug, bool $publishedOnly = true): ?Listing;
public function getBySlugOrId(string $slugOrId, bool $publishedOnly = true): ?Listing;
}Search Strategies:
- Typesense Search - When
queryparameter is provided (full-text search) - PostGIS Search - When geographic filters are provided (Google Place ID or lat/lng)
- Eloquent Search - Fallback for simple filtering (categories, tags, price, etc.)
CategoryService
Location: modules/Listing/Services/CategoryService.php
Responsibilities:
- Manage listing categories
- Category search and autocomplete
- Category hierarchy management
TagService
Location: modules/Listing/Services/TagService.php
Responsibilities:
- Manage listing tags
- Tag search and autocomplete
- Tag normalization and deduplication
API Endpoints
Public Endpoints
GET /api/v1/listings/search # Search listings with filters
GET /api/v1/listings/{slugOrId} # Get listing details by slug or ID
GET /api/v1/listings/{slugOrId}/gallery # Get listing gallery images
GET /api/v1/listings/categories # Get all listing categories
GET /api/v1/listings/categories/autocomplete # Search categories
GET /api/v1/listings/tags/autocomplete # Search tagsAuthenticated Vendor Endpoints
PATCH /api/v1/listings/{listingId}/media/{mediaId} # Update gallery media (order, collection)Search Parameters
Text Search:
query- Full-text search across title, description, vendor name, location
Geographic Search:
google_place_id- Google Place ID for region-based search (any overlap logic)latitude+longitude- Specific coordinates (point-in-circle logic)radius_km- Search radius in kilometers (optional, for future use)
Filters:
categories- Comma-separated category namestags- Comma-separated tag namesverified- Boolean filter for verified listingsfeatured- Boolean filter for featured listingsmin_price- Minimum price filtermax_price- Maximum price filter
Sorting:
order_by- Sort field (price, rating, review_count, created_at, published_at)order_dir- Sort direction (asc, desc)
Pagination:
per_page- Results per page (default: 15, max: 100)
Search Response Format
See API Documentation for details.
Search Region Metadata (when geographic search is used):
type- "circle" for Google Place ID searches, "point" for lat/lng searchescenter- Search center point{ lat, lng }radius_miles- Search radius in miles (only for Google Place ID searches)source- "google_place_id" or "coordinates"
This metadata enables the frontend to visualize the search area on a map.
Show Listing Endpoint
GET /api/v1/listings/{slugOrId}
Get detailed information about a specific listing by slug or UUID.
Parameters:
slugOrId- Listing slug (e.g., "premium-wedding-photography") or UUID
Response Format:
{
"data": {
"id": "uuid",
"vendor_id": "uuid",
"title": "Premium Wedding Photography",
"slug": "premium-wedding-photography",
"description": "Full day coverage...",
"excerpt": "Professional wedding photography...",
"price": 2500.00,
"currency": "GBP",
"location": "London",
"based_in": "London, UK",
"latitude": 51.5074,
"longitude": -0.1278,
"service_radius_miles": 50,
"status": "published",
"verified": true,
"featured": false,
"rating": 4.8,
"review_count": 42,
"view_count": 1250,
"years_experience": 10,
"package_inclusions": [...],
"published_at": "2024-01-15T10:00:00Z",
"created_at": "2024-01-10T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"vendor": {
"id": "uuid",
"business_name": "ABC Photography",
"slug": "abc-photography"
},
"categories": [...],
"tags": [...],
"featured_image": {
"id": "uuid",
"url": "https://...",
"filename": "featured.jpg",
"title": "Featured Image",
"alt_text": "Wedding photography"
}
}
}Note: Only returns listings with status = 'published'.
Gallery Endpoint
GET /api/v1/listings/{slugOrId}/gallery
Returns all gallery images for a listing.
Response Format:
{
"data": [
{
"id": "uuid",
"path": "listings/gallery/image.jpg",
"filename": "image.jpg",
"mime_type": "image/jpeg",
"size": 1024000,
"title": "Wedding ceremony setup",
"alt_text": "Beautiful outdoor ceremony",
"url": "https://storage.example.com/listings/gallery/image.jpg",
"collection": "gallery",
"order": 1
}
]
}Update Gallery Media Endpoint (Authenticated)
PATCH /api/v1/listings/{listingId}/media/{mediaId}
Update gallery media order or collection. Requires vendor authentication and ownership.
Request Body:
{
"order": 2,
"collection": "gallery"
}Response:
{
"message": "Media updated successfully",
"data": {
"id": "uuid",
"path": "listings/gallery/image.jpg",
"filename": "image.jpg",
"mime_type": "image/jpeg",
"size": 1024000,
"title": "Wedding ceremony setup",
"alt_text": "Beautiful outdoor ceremony",
"url": "https://storage.example.com/listings/gallery/image.jpg",
"collection": "gallery",
"order": 2
}
}Categories Endpoints
GET /api/v1/listings/categories
Get all listing categories.
Response Format:
{
"data": [
{
"id": "uuid",
"slug": "photography",
"label": "Photography"
},
{
"id": "uuid",
"slug": "videography",
"label": "Videography"
}
]
}GET /api/v1/listings/categories/autocomplete?query={search}
Search categories by name for autocomplete functionality.
Parameters:
query- Search term (optional)
Response Format:
[
{
"id": "uuid",
"slug": "photography",
"label": "Photography"
}
]Tags Endpoint
GET /api/v1/listings/tags/autocomplete?query={search}
Search tags by name for autocomplete functionality.
Parameters:
query- Search term (optional)
Response Format:
[
{
"id": "uuid",
"slug": "outdoor-wedding",
"label": "Outdoor Wedding"
}
]Geographic Search Architecture
Two-Step Search Process
The listing search uses a sophisticated two-step process combining PostGIS spatial queries with Typesense full-text search:
PostGIS filters listings by geographic area (PostgreSQL)
- Google Places:
overlapsWithRegion(centerLat, centerLng, radiusMiles)- "any overlap" logic - Lat/Lng:
withinServiceAreaOf(latitude, longitude)- "point-in-circle" logic - Returns array of listing IDs that match geographic criteria
- Google Places:
Typesense performs full-text search within filtered IDs
- Applies non-geographic filters (categories, tags, price, etc.)
- ID filter restricts search to PostGIS-filtered listings
- Provides fast full-text search and faceting
PostGIS Spatial Queries
Google Place ID Search (Any Overlap Logic):
-- Find listings whose service area overlaps with the Google Place region
-- Overlap occurs when: distance_between_centers <= listing_radius + region_radius
ST_DWithin(
service_center::geography,
ST_SetSRID(ST_MakePoint(region_lng, region_lat), 4326)::geography,
(service_radius_miles + region_radius_miles) * 1609.34 -- Convert miles to meters
)Lat/Lng Search (Point-in-Circle Logic):
-- Find listings whose service area contains the search point
ST_DWithin(
service_center::geography,
ST_SetSRID(ST_MakePoint(search_lng, search_lat), 4326)::geography,
service_radius_miles * 1609.34 -- Convert miles to meters
)Model Scopes
The Listing model provides convenient scopes for geographic queries:
// Point-in-circle: Find listings that service a specific location
Listing::withinServiceAreaOf(51.5074, -0.1278)->get();
// Any overlap: Find listings that overlap with a region
Listing::overlapsWithRegion(51.5074, -0.1278, 10.5)->get();
// Multi-location: Find listings that service ALL locations
Listing::coversAllLocations([
['latitude' => 51.5074, 'longitude' => -0.1278], // Ceremony
['latitude' => 51.5155, 'longitude' => -0.1426], // Reception
])->get();Database Schema
listings table
Key Fields:
id- UUID primary keyvendor_id- Foreign key to vendors tabletitle,slug- Listing title and URL slugdescription,excerpt- Content fieldsprice,price_extras_description,max_hours,currency- Pricinglatitude,longitude- Listing location coordinatesservice_center- PostGIS geography(Point, 4326) for spatial queriesservice_radius_miles- Service area radiustravel_included_miles- Free travel distancecoverage_min_lat,coverage_min_lng,coverage_max_lat,coverage_max_lng- Legacy rectangular bounds (preserved for regression testing)status- ENUM(draft, pending, published, archived)verified,featured- Boolean flagsrating,review_count,view_count- Metricsyears_experience- Provider experiencefeatured_image_id- Foreign key to media tablepackage_inclusions- JSON arrayexternal_reviews- JSON array- SEO fields:
meta_title,meta_description,focus_keyword,canonical_url - Open Graph fields:
og_title,og_description,og_image_url - Twitter Card fields:
twitter_title,twitter_description,twitter_image_url - Booking configuration:
calendar_sync_url,max_booking_days,min_booking_days,booking_lead_time,booking_window_days,requires_approval published_at,created_at,updated_at,deleted_atwp_id- WordPress migration tracking
Indexes:
- Primary key on
id - Foreign key on
vendor_id - Unique index on
slug - Unique index on
wp_id - Composite index on
[status, published_at] - Composite index on
[vendor_id, status] - Composite index on
[latitude, longitude] - GIST spatial index on
service_center(PostGIS)
categories table
Listing categories (e.g., "Wedding Photographers", "Wedding Venues").
Pivot: categorizables (polymorphic many-to-many)
tags table
Listing tags for search and filtering.
Pivot: taggables (polymorphic many-to-many)
Relationships
Belongs To
Vendor- Each listing belongs to one vendor
Has Many
Reviews- Listing can have reviewsBookings- Listing receives booking requestsFavourites- Users can favourite listings
Many to Many
Categories- Listing can belong to multiple categoriesTags- Listing has searchable tagsMedia- Listing has images, videos, documents
Integration Points
Search Domain (Typesense)
Listings are indexed in Typesense for fast full-text search and faceted filtering.
Searchable Fields:
public function toSearchableArray(): array
{
return [
'id' => (string) $this->id,
'title' => $this->title,
'description' => $this->description ?? '',
'price' => $this->price ? (float) $this->price : 0.0,
'vendor_id' => (string) $this->vendor_id,
'vendor_name' => $this->vendor?->business_name ?? '',
'location' => $this->location ?? '',
'based_in' => $this->based_in ?? '',
'status' => $this->status,
'verified' => (bool) $this->verified,
'featured' => (bool) $this->featured,
'rating' => $this->rating ? (float) $this->rating : 0.0,
'review_count' => (int) $this->review_count,
'view_count' => (int) $this->view_count,
'categories' => $this->categories->pluck('name')->toArray(),
'tags' => $this->tags->pluck('name')->toArray(),
'published_at' => $this->published_at?->timestamp,
'created_at' => $this->created_at->timestamp,
];
}Note: Geographic fields (latitude, longitude, service_center, coverage bounds) are not indexed in Typesense. Geographic filtering is handled by PostGIS before Typesense search.
Search Features:
- Full-text search across title, description, vendor name, location
- Faceted filtering by categories, tags, price, verified, featured
- Sorting by price, rating, review_count, created_at, published_at
- Auto-sync on model create/update/delete via Laravel Scout
Media Domain
Listings use the polymorphic media system with the following collections:
featured_image- Primary listing image (single)gallery- Additional listing images (multiple, ordered)
Relationship:
$listing->media()->wherePivot('collection', 'gallery')->get();
$listing->featuredImage; // BelongsTo relationship via featured_image_idVendors Domain
- Each listing belongs to one vendor
- Vendor status affects listing visibility
- Vendor verification required for verified listings
- Vendor's
business_nameis indexed in Typesense for search
Google Places API
- Converts Google Place IDs to viewport bounds (northeast/southwest corners)
- Calculates center point and radius using Haversine formula
- Validates Place IDs are within allowed countries (UK only currently)
- Used for region-based geographic searches
Testing Strategy
Test Coverage
Location: modules/Listing/Tests/
Feature Tests (modules/Listing/Tests/Feature/ListingPublicApiTest.php):
- ✅ 61 tests covering all search scenarios
- Geographic search (Google Place ID and lat/lng)
- Text search with Typesense
- Category and tag filtering
- Price range filtering
- Verified and featured filters
- Pagination and sorting
- Search region metadata
- Geographic restrictions (UK only)
- Edge cases and error handling
Key Test Examples:
it('returns search region metadata for Google Place ID searches', function () {
// ... test implementation
expect($response->json('meta.search_region.type'))->toBe('circle');
expect($response->json('meta.search_region.radius_miles'))->toBe(10.5);
});
it('performs geographic search by Google Place ID (PostGIS any overlap)', function () {
// ... test implementation
$response->assertSuccessful();
});
it('only returns published listings', function () {
// ... test implementation
expect($response->json('data'))->each->toHaveKey('status', 'published');
});Running Tests
# Run all listing tests
docker compose exec -T wedissimo-api vendor/bin/pest modules/Listing/Tests/
# Run specific test file
docker compose exec -T wedissimo-api vendor/bin/pest modules/Listing/Tests/Feature/ListingPublicApiTest.php
# Run specific test
docker compose exec -T wedissimo-api vendor/bin/pest --filter="returns search region metadata"Livewire Components
ListingSearch Component
Location: modules/Listing/Livewire/ListingSearch.php
Features:
- Real-time search with debouncing and throttling
- Category and tag autocomplete
- Price range filtering
- Verified/featured filters
- Geographic search (Google Places integration)
- Sortable columns
- Pagination
- Query string persistence
- Loading states
CategoryAutocomplete Component
Location: modules/Listing/Livewire/CategoryAutocomplete.php
Features:
- Real-time category search
- Keyboard navigation
- Multi-select support
TagAutocomplete Component
Location: modules/Listing/Livewire/TagAutocomplete.php
Features:
- Real-time tag search
- Keyboard navigation
- Multi-select support
ListingEdit Component
Location: modules/Listing/Livewire/ListingEdit.php
Features:
- Listing creation and editing
- Media upload and management
- Category and tag assignment
- SEO field management
- Geographic location selection
- Service radius configuration
Key Features
Search Region Metadata
When performing geographic searches, the API returns metadata about the search area that can be used to visualize the search on a map.
Use Case: QA and users can verify that listings are correctly returned based on their service area coverage.
Example Scenario:
- User searches for: London (lat: 51.5074, lng: -0.1278)
- Provider location: Colchester (50 miles from London)
- Provider service radius: 90 miles
- Result: Provider appears in results because London is within their 90-mile service area
Frontend Visualization:
- Plot the search point using
meta.search_region.center - Plot the provider's location using listing's
latitude/longitude - Draw the provider's service circle using
service_radius_miles - Verify visually that the search point falls inside the provider's service area
Response Format:
{
"meta": {
"search_region": {
"type": "point",
"center": { "lat": 51.5074, "lng": -0.1278 },
"source": "coordinates"
}
}
}For Google Place ID searches, the response includes radius_miles:
{
"meta": {
"search_region": {
"type": "circle",
"center": { "lat": 51.5074, "lng": -0.1278 },
"radius_miles": 10.5,
"source": "google_place_id"
}
}
}PostGIS Spatial Indexing
The module uses PostGIS for true circular service area calculations with Earth-accurate distance calculations (Haversine formula).
Benefits:
- Accurate geographic searches (vs rectangular bounding boxes)
- Efficient spatial indexing with GIST
- Support for complex multi-location searches
- "Any overlap" logic for region searches
- "Point-in-circle" logic for specific location searches
Auto-Sync: The service_center geography point is automatically synced from latitude/longitude via model booted() method.
Calendar Sync & Availability
The Listing module includes a calendar synchronization system that imports blocked dates from external iCal feeds (Google Calendar, Airbnb, Outlook, etc.) to manage vendor availability.
How It Works
- Vendors can set a
default_calendar_sync_url(iCal feed) at the vendor level - Listings can have their own
calendar_sync_urlthat overrides or supplements the vendor default - A scheduled job fetches calendars and stores blocked dates per-listing
- Search API excludes listings that are blocked on the requested date
Calendar URL Resolution
When syncing a listing, the system collects calendar URLs using merge logic:
| Listing URL | Vendor Default | URLs Synced |
|---|---|---|
| Set | Set (different) | Both - merged |
| Set | Set (same URL) | One - deduplicated |
| Set | Not set | Listing URL only |
| Not set | Set | Vendor default only |
| Not set | Not set | No sync |
Key Point: When both listing and vendor have different calendar URLs, blocked dates from both calendars are merged. This allows:
- Vendor-level holidays and personal time off (vendor default calendar)
- Listing-specific bookings (listing calendar)
Blocked Date Storage
Blocked dates are stored in the listing_availability table:
listing_availability (
id UUID PRIMARY KEY,
listing_id UUID NOT NULL, -- FK to listings
blocked_date DATE NOT NULL, -- The blocked day (full day, not time slot)
event_uid VARCHAR(255), -- Original iCal event UID
event_summary VARCHAR(255), -- Event title from calendar
sync_hash VARCHAR(64), -- For change detection (reserved)
synced_at TIMESTAMP, -- When this record was last synced
UNIQUE (listing_id, blocked_date)
)Important: The system blocks entire days, not time slots. A 1-hour event on June 15th blocks the entire day.
Incremental Sync Optimization
To avoid unnecessary database writes, the sync uses two-level change detection:
HTTP ETag Header
- Sends
If-None-Matchheader with stored ETag - If server returns
304 Not Modified, skips sync entirely - Stored per-listing in
listings.calendar_feed_etag
- Sends
iCal LAST-MODIFIED Property
- Extracts max
LAST-MODIFIEDfrom all events - Compares to stored
listings.calendar_last_modified - Skips sync if unchanged
- Extracts max
Sync Decision Flow:
1. Fetch calendar with If-None-Match header
2. If 304 response → Update timestamp only, done
3. If 200 response → Parse iCal content
4. If LAST-MODIFIED unchanged → Update timestamp only, done
5. Otherwise → Full sync (DELETE + INSERT all dates)Listing Model Fields
// Calendar sync configuration
'calendar_sync_url' // Listing-specific iCal URL (optional)
'calendar_feed_etag' // Stored ETag for conditional requests
'calendar_last_modified' // Max LAST-MODIFIED from last sync
'calendar_last_synced_at' // Timestamp of last sync attemptServices & Jobs
ICalParserService (modules/Listing/Services/ICalParserService.php)
// Fetch and parse with conditional request support
$result = $parser->fetchAndParse($url, $previousEtag);
// Returns: ['dates' => [...], 'etag' => '...', 'last_modified' => Carbon]
// Returns null if 304 Not Modified
// Backward-compatible simple method
$dates = $parser->getBlockedDateMap($url);ListingAvailabilityService (modules/Listing/Services/ListingAvailabilityService.php)
// Sync blocked dates for a listing (full replace)
$service->syncListingAvailability($listingId, $dateMap);SyncListingCalendarJob (modules/Listing/Jobs/SyncListingCalendarJob.php)
// Dispatch sync for a listing
SyncListingCalendarJob::dispatch($listingId);
// Force full sync (ignore ETag/LAST-MODIFIED)
SyncListingCalendarJob::dispatch($listingId, forceSync: true);SyncAllListingCalendarsCommand (modules/Listing/Console/SyncAllListingCalendarsCommand.php)
# Sync all published listings with calendar URLs
php artisan calendar:sync-listings
# Sync for specific vendor
php artisan calendar:sync-listings --vendor-id=uuid
# Limit number of jobs dispatched
php artisan calendar:sync-listings --limit=100Search Integration
When searching with a date parameter, blocked listings are excluded:
// ListingService.php
if (isset($filters['date'])) {
$query->whereDoesntHave('availability', function ($q) use ($date) {
$q->where('blocked_date', $date);
});
}API Usage:
GET /api/v1/listings/search?date=2025-12-25Returns only listings that are not blocked on December 25, 2025.
Configuration
// config/listing.php (or module config)
'calendar' => [
'sync_interval_minutes' => env('CALENDAR_SYNC_INTERVAL_MINUTES', 15),
'sync_queue' => env('CALENDAR_SYNC_QUEUE', 'calendar-sync'),
'sync_horizon_years' => env('CALENDAR_SYNC_HORIZON_YEARS', 2),
],Testing
# Run calendar sync tests
docker compose exec -T wedissimo-api vendor/bin/pest modules/Listing/Tests/Feature/SyncListingCalendarJobTest.php
docker compose exec -T wedissimo-api vendor/bin/pest modules/Listing/Tests/Unit/ICalParserServiceTest.phpTest Coverage:
- Conditional sync (304 Not Modified)
- LAST-MODIFIED change detection
- Force sync option
- Vendor default fallback
- Calendar merge (listing + vendor)
- URL deduplication
- Missing calendar handling
Limitations
- Full-day blocking only: Events block entire days, not specific time slots
- No recurring event optimization: Each occurrence of a recurring event creates a separate blocked date record
- Sync horizon: Only syncs events within configured horizon (default: 2 years)
Future Enhancements
- Time-slot blocking (instead of full-day)
- Real-time booking confirmation with fresh iCal fetch
- Webhook support for instant calendar updates
- Calendar export (generate iCal feed for listings)
Future Enhancements
Domain Events (Planned)
The following domain events are planned but not yet implemented:
ListingCreated- Dispatched when a new listing is createdListingPublished- Dispatched when listing status changes to publishedListingFeatured- Dispatched when listing is promoted to featuredListingArchived- Dispatched when listing is archivedListingUpdated- Dispatched when listing details are modified
These events will enable:
- Automatic search index updates
- Vendor notifications
- Analytics tracking
- Integration with other domains (Bookings, Communications, etc.)
Feature Enhancements
- Multi-location search UI (ceremony + reception + accommodation)
- Distance-based sorting (nearest first)
- Service area visualization on listing detail pages
- Heatmap of listing coverage density
- Listing packages (Bronze, Silver, Gold tiers)
- Dynamic pricing based on season/demand
- Listing templates for quick creation
- AI-powered description suggestions
- Automated quality scoring
- Competitor price analysis
- Listing performance analytics
- A/B testing for listing content
- Multi-language listing support