Listings / Marketplace Module Guide¶
Last reviewed: 2026-06-23
Audience: maintainers and contributors working on the listings module — the offer/request board at the heart of the timebanking workflow.
Overview¶
Listings are the primary way members advertise services they can offer or request from their community. Every listing is classified as either an offer (member can provide the service) or a request (member needs the service). An approved listing with status = active and moderation_status = approved is visible to all members of the same tenant; unauthenticated visitors can browse but cannot initiate an exchange.
Separate from the time-credit listings module, a Marketplace subsystem (/v2/marketplace/*) handles physical-goods commerce with pricing, inventory, escrow, seller profiles, and delivery. The marketplace uses its own marketplace_listings table and service layer. This guide covers the timebanking listings module; for the Marketplace, see app/Services/MarketplaceListingService.php and the routes grouped under /v2/marketplace/ in routes/api.php.
Audience and supported workflows¶
Use this guide when changing listing creation, moderation, expiry, search indexing, or the path from a listing to an exchange request.
Supported workflows:
- Browse and search — any visitor can list, filter, and search active approved listings.
- Create offer or request — authenticated members create listings; if moderation is enabled they enter
pending_reviewfirst. - Edit / pause / delete — the listing owner or an admin can update fields, pause a listing (
status = paused), or soft-delete it (status = deleted). - Renew — owners extend the expiry date by 30 days (max 12 renewals per listing).
- Favourite / save — authenticated members can save listings to a personal list.
- Moderation — admins review the pending queue, approve or reject with a reason.
- Featured / boosted — admins pin listings to the top of the browse page via
is_featured. - Initiate an exchange — a member clicks "Request this service" on a listing; this creates an
exchange_requestsrow that begins the exchange workflow (see docs/modules/wallet-exchanges.md). - Analytics — listing owners can view their own view/contact/save counts.
Tenant and feature-gate rules¶
- Module gate:
listings. All listing routes are wrapped inRoute::middleware('module:listings')inroutes/api.php. The React frontend wraps the listings pages in<FeatureGate module="listings" redirect="/" />. - The module defaults to enabled (
listings: truein the ReactdefaultFeaturesmap and inTenantContext::FEATURE_DEFAULTS). A tenant admin can disable it to hide the board entirely. - Tenant scoping is enforced by the
HasTenantScopetrait onApp\Models\Listing. Every query automatically includesWHERE tenant_id = <current>. Never bypass this; always useTenantContext::getId()in raw queries. - Category resolution (slug → id) is also tenant-scoped:
WHERE type = 'listing' AND tenant_id = ?. - The
user_saved_listingstable carries an explicittenant_idcolumn; favourite operations filter on it.
Key code and data locations¶
Routes are in routes/api.php (search for module:listings). Do not copy the full endpoint table here — read the route file directly. Primary entry points:
| Concern | Route prefix | Controller |
|---|---|---|
| Public browse / search / nearby | GET /v2/listings |
App\Http\Controllers\Api\ListingsController |
| Single listing detail | GET /v2/listings/{id} |
App\Http\Controllers\Api\ListingsController |
| CRUD (create / update / delete) | POST/PUT/DELETE /v2/listings |
App\Http\Controllers\Api\ListingsController |
| Renew / analytics / tags / report | POST /v2/listings/{id}/renew etc. |
App\Http\Controllers\Api\ListingsController |
| Featured listings | GET /v2/listings/featured |
App\Http\Controllers\Api\ListingsController |
| Admin moderation queue | /v2/admin/listings/* |
App\Http\Controllers\Api\AdminListingsController |
Services:
app/Services/ListingService.php— all listing CRUD, public visibility filter, cursor pagination, Haversine proximity search, and thecanModify()permission check.app/Services/ListingModerationService.php— approve / reject workflow, review queue, moderation stats.app/Services/ListingExpiryService.php— processes expired listings across tenants (scheduled), and one-click renewal (max 12 renewals, +30 days per renewal).app/Services/ListingFeaturedService.php— sets/clearsis_featuredandfeatured_until.app/Services/ListingConfigurationService.php— typed tenant config with Redis cache (5-minute TTL). Keys live intenant_settingswith alisting.prefix.app/Services/ListingSkillTagService.php— tag CRUD onlisting_skill_tags; autocomplete and popular tags.app/Services/ListingAnalyticsService.php— records views (dedup by IP hash), increments save counts, returns owner analytics.app/Services/ListingRankingService.php— post-query MatchRank re-ranking (engagement × quality × reciprocity × personalisation score). Applied inListingsController::index()unlesssort=newestor proximity is active.
Models and tables:
| Model / table | Purpose |
|---|---|
App\Models\Listing / listings |
Core listing row |
listing_skill_tags |
Many-to-many skill tags on a listing |
listing_images / App\Models\ListingImage |
Up to N images per listing (configurable, default 5) |
listing_views |
Anonymous + authenticated view events (dedup by IP hash) |
listing_reports / App\Models\ListingReport |
Community abuse reports awaiting admin review |
user_saved_listings |
Favourites (userId × listingId × tenantId) |
categories |
Tenant-scoped listing categories (type = 'listing') |
exchange_requests |
Exchanges initiated from a listing (listing_id FK) |
Frontend entry points (React):
react-frontend/src/pages/listings/ListingsPage.tsx— browse / filter UI.react-frontend/src/pages/listings/ListingDetailPage.tsx— single listing with reciprocity panel, member offers/requests, "Request this service" CTA.react-frontend/src/pages/listings/CreateListingPage.tsx— creation form with AI description helper.react-frontend/src/components/compose/tabs/ListingTab.tsx— quick-create listing from the global compose drawer.
Listing data model¶
Key columns on the listings table:
| Column | Type | Notes |
|---|---|---|
tenant_id |
int | FK to tenants.id; enforced by HasTenantScope |
user_id |
int | Listing owner; FK to users.id (CASCADE DELETE) |
type |
varchar(50) | offer or request — the core distinction |
status |
enum | active, draft, paused, expired, deleted, pending, rejected, closed, inactive, completed |
moderation_status |
enum | NULL (not moderated), pending_review, approved, rejected |
hours_estimate |
decimal(5,2) | Suggested time cost in hours (0.5–2000) |
service_type |
enum | physical_only, remote_only, hybrid, location_dependent |
federated_visibility |
enum | none, listed, bookable — controls federation partner visibility |
is_featured |
tinyint | Set by admin; pins listing to top of browse page |
featured_until |
datetime | Optional expiry for the featured window |
expires_at |
datetime | Auto-expiry date set by listing.auto_expire_days config |
renewal_count |
int unsigned | Times renewed; capped at listing.max_renewals (default 12) |
sdg_goals |
JSON | Optional UN Sustainable Development Goals tags (integers 1–17) |
availability |
JSON | Free-form availability schedule |
direct_messaging_disabled |
tinyint | Disables direct contact; forces use of the exchange workflow |
exchange_workflow_required |
tinyint | Marks listing as requiring formal exchange |
Public visibility rule (enforced in ListingService::applyPublicVisibility()): a listing is visible to members only when (status IS NULL OR status = 'active') AND (moderation_status IS NULL OR moderation_status = 'approved'). Draft, paused, expired, deleted, pending, and rejected listings are hidden from non-owners.
Offer / request model¶
The type column is the single source of truth:
offer— the listing author is offering a service; any member can request it.request— the author needs help; any member can respond.
Both types use the same table, routes, and exchange workflow. The tenant can disable one or both types via listing.allow_offers / listing.allow_requests configuration keys. An attempt to create a listing of a disabled type returns HTTP 422.
Lifecycle: create → active → expired¶
POST /v2/listings
│
├─ moderation enabled? ──yes──► status=pending, moderation_status=pending_review
│ │
│ admin reviews
│ ├── approve ──► status=active, moderation_status=approved → feed_activity
│ └── reject ──► status=rejected, moderation_status=rejected + reason
│
└─ moderation disabled? ─────► status=active, moderation_status=approved → feed_activity
│
expires_at reached?
│ (cron: ListingExpiryService::processAllTenants)
▼
status=expired → expiry email + bell notification
│
owner renews?
(POST /v2/listings/{id}/renew)
▼
status=active, expires_at += 30 days
Status transitions controlled by ListingService::update():
- Non-admin callers may only set
active,draft, orpaused. - Setting
status=activeon adeleted,suspended, orrejectedlisting is blocked for non-admin callers. deletedstatus is set byListingService::delete()only; it is a soft delete — the row remains.
Soft delete side effects (all within the same tenant scope):
listing_skill_tags,user_saved_listings,listing_views,listing_contactsrows for the listing are deleted.SearchService::removeListing()removes the document from the Meilisearchlistingsindex.FeedActivityService::removeActivity('listing', $id)removes the feed entry.
Categories and skill tags¶
Categories are managed per tenant under categories WHERE type = 'listing'. They carry name, color, and slug. Tenants control via admin UI whether a category is required (listing.require_category defaults to true). The API accepts either category_id (integer) or category (slug, resolved to ID inside the service).
Skill tags live in listing_skill_tags (one row per tag per listing). They are normalised to lowercase. The API endpoint PUT /v2/listings/{id}/tags replaces the full tag set atomically. Tags are indexed in the Meilisearch listings index as an array field skill_tags, enabling filter expressions like skill_tags = 'gardening'.
- Popular tags:
GET /v2/listings/tags/popular - Autocomplete (min 2 chars):
GET /v2/listings/tags/autocomplete?q=<prefix>
Search indexing¶
Listings are indexed in Meilisearch under the listings index. See docs/modules/search.md for the index schema, ranking rules, and synonym map.
Indexing trigger: the ListingCreated event fires after a new listing is saved to the database. The queued listener App\Listeners\UpdateFeedOnListingCreated calls SearchService::indexListing($event->listing). The same listener updates feed_activity. Index updates on edit are handled at save time via model observers.
Index document shape:
| Field | Source |
|---|---|
id, tenant_id, user_id |
listings columns |
title, description, location |
listings columns |
type, category_id, category_name |
listings + categories join |
status, moderation_status |
listings columns |
skill_tags |
listing_skill_tags.tag[] |
author_name |
users.first_name + last_name |
created_at |
Unix timestamp |
Search fallback: when Meilisearch is unreachable ListingService::getAll() falls through to a SQL LIKE query on title, description, and location. Faceted filters (hours range, service type, posted-within) are always applied in SQL, even when Meilisearch provides the initial ID list — this prevents pagination drift from post-search filtering.
Manual re-index:
Moderation¶
Moderation is opt-in per tenant (listing.moderation_enabled, default false). When enabled:
- New listings enter
status=pending, moderation_status=pending_reviewand are invisible to non-owners. - Admins see the queue via
GET /v2/admin/listings/moderation/queue(served byAdminListingsController). ListingModerationService::approve()sets bothstatus=activeandmoderation_status=approved, writes afeed_activityrow, and sends a bell + push notification to the owner in theirpreferred_language.ListingModerationService::reject()requires a non-empty reason; sends bell notification and email to the owner. The listing is not auto-deleted — the owner can edit and re-submit.
Community reporting (independent of the admin moderation queue): authenticated members can report a listing once via POST /v2/listings/{id}/report. Accepted reasons: inappropriate, safety_concern, misleading, spam, not_timebank_service, other. A report creates a listing_reports row and fires a moderation-alert notification to admin users.
Featured listings¶
Admins set is_featured = 1 (with an optional featured_until date) via POST /v2/admin/listings/{id}/feature. Featured listings are returned by GET /v2/listings/featured and receive a boost in the MatchRank scorer (FEATURED_BOOST). The ListingFeaturedService enforces that featured_until cannot exceed 365 days from now.
Exchange workflow integration¶
A listing is the entry point for an exchange. The "Request this service" button on ListingDetailPage creates a row in exchange_requests with:
listing_idFK to the listing.requester_id— the member initiating.provider_id— the listing owner.proposed_hours— negotiated at request time.
exchange_requests.listing_id has an ON DELETE CASCADE constraint, so deleting a listing also removes its pending exchange requests. Do not delete a listing that has in-progress exchanges — check exchange_requests first or use status=paused instead.
For the full exchange state machine and credit transfer, see docs/modules/wallet-exchanges.md.
Tenant configuration keys¶
All keys stored in tenant_settings with a listing. prefix. Managed by ListingConfigurationService. A 5-minute Redis cache per tenant is invalidated on every set() call.
| Key | Default | Description |
|---|---|---|
listing.moderation_enabled |
false |
Enable admin review queue |
listing.allow_offers |
true |
Allow type=offer listings |
listing.allow_requests |
true |
Allow type=request listings |
listing.max_per_user |
50 |
Max active listings per user (0 = unlimited) |
listing.max_images |
5 |
Max images per listing |
listing.require_category |
true |
Category required on create |
listing.require_location |
false |
Location required on create |
listing.require_hours_estimate |
false |
Hours estimate required |
listing.auto_expire_days |
0 |
Days until auto-expiry (0 = never) |
listing.max_renewals |
12 |
Maximum renewals per listing |
listing.enable_featured |
true |
Enable featured listings |
listing.enable_ai_descriptions |
true |
Enable AI description generator |
listing.enable_favourites |
true |
Enable save/favourite |
listing.enable_reporting |
true |
Enable community reporting |
listing.enable_map_view |
true |
Enable map/proximity search |
Security and privacy invariants¶
- Owner-only mutations:
ListingService::canModify()returnstrueonly for the listing owner or a user withrole IN ('admin', 'tenant_admin')oris_super_admin. The controller enforces this before any write. - No cross-tenant data: all queries carry
tenant_idfromTenantContext::getId(). TheHasTenantScopetrait is applied at the model level. - Hidden listings: a non-owner viewing a listing in
pending,rejected,deleted,draft, orpausedstatus receives HTTP 404 (treated as not found), not 403 — to avoid revealing that a listing exists. - Image upload validation: only JPEG, PNG, WebP, and GIF are accepted; files over 8 MB are rejected before upload to cloud storage.
- Report deduplication: a user can only report a given listing once (409 on duplicate). Self-reports return 403.
- Skill tag injection:
SearchService::buildEqFilter()andassertFilterableField()whitelist which Meilisearch filter fields can be constructed from user input.
Failure modes and recovery¶
| Failure | Behaviour | Recovery |
|---|---|---|
| Meilisearch unavailable at search time | Falls back to SQL LIKE; result shape unchanged | Restore Meilisearch, then run sync_search_index.php --type=listing to backfill any missed updates |
| Meilisearch unavailable at create/delete time | Index update silently skipped; listing is live in the database | Run sync_search_index.php --type=listing --tenant=<id> to re-sync |
| Listing expiry cron fails for a tenant | Listings remain active past expires_at; owner does not receive expiry email |
Re-run ListingExpiryService::processAllTenants() manually; it is idempotent |
listing.max_per_user cap reached |
Create returns HTTP 422 with VALIDATION_ERROR |
Admin can raise the cap in tenant settings, or the member deletes an old listing |
| In-progress exchange when listing deleted | exchange_requests rows cascade-deleted |
Do not delete a listing with in-progress exchanges. Check exchange_requests WHERE listing_id = ? AND status NOT IN ('completed','cancelled') first |
| Featured listing not expiring | featured_until stored but never enforced on display — getFeaturedListings() filters featured_until > now() |
No action needed; featured status expires automatically on next browse request |
| Moderation queue stuck (no admin action) | Listing stays pending_review; owner sees no update |
Admin must process the queue; there is no automatic escalation |
Tests¶
# PHP — run from repo root
vendor/bin/phpunit --testsuite=Laravel --filter=Listing
vendor/bin/phpunit tests/Laravel/Feature/Controllers/ListingsControllerTest.php
vendor/bin/phpunit tests/Laravel/Unit/Services/ListingExpiryReminderServiceTest.php
vendor/bin/phpunit tests/Laravel/Integration/ListingEmailReliabilityTest.php
# React — run from react-frontend/
npm test -- ListingsPage
npm test -- ListingDetailPage
npm test -- CreateListingPage
Key PHP test files:
| File | What it covers |
|---|---|
tests/Laravel/Feature/Controllers/ListingsControllerTest.php |
CRUD, auth, ownership, pagination, module gate |
tests/Laravel/Feature/Controllers/AdminListingsControllerTest.php |
Moderation approve/reject, featured toggle |
tests/Laravel/Unit/Services/ListingExpiryReminderServiceTest.php |
Expiry reminder emails, renewal counting |
tests/Laravel/Unit/Services/ListingRankingServiceTest.php |
MatchRank scorer, reciprocity signal |
tests/Laravel/Integration/ListingEmailReliabilityTest.php |
Creation and expiry emails rendered in recipient locale |
tests/Laravel/Feature/Listeners/UpdateFeedOnListingCreatedTest.php |
Feed activity and Meilisearch indexing on ListingCreated event |
tests/Laravel/Unit/Models/ListingTest.php |
Model scopes, visibility, status transitions |