To build a Laravel directory website, you need five core components: a listings data model with categories and locations, fast search using Laravel Scout, a submission and moderation workflow, monetization through featured listings or paid plans, and SEO-friendly category and city pages. Laravel provides the tools to build all five, and an AI app builder can help you generate the first working version in a day.
Directories are one of the most reliable indie business models on the web. A focused directory, such as "vegan restaurants in Lisbon," "remote-friendly co-working spaces," or "Laravel agencies in Europe," can rank in less competitive search results and earn revenue through featured placements, subscriptions, and leads.
Here's the challenge many people face. A WordPress directory theme gets you online in a day, but custom features can become difficult to implement: radius search, claim flows, tiered plans, and moderation rules. A Laravel business directory gives you greater control over all of them.
This guide walks you through the build step by step, with code you can adapt.
Key Takeaways
Start with the data model: Listings, categories, cities, reviews, and claims, with a
statuscolumn to prevent unmoderated content from going live.Use Laravel Scout for search. The database engine is enough to get started; switch to Meilisearch or Typesense for typo tolerance and map-radius search.
Moderate everything. User-submitted listings and reviews need a pending state, rate limits, and an admin queue.
Monetize from day one with featured listings through Stripe Checkout via Laravel Cashier.
SEO drives growth. Category and city pages, clean slugs, and LocalBusiness schema can help attract directory traffic.
The founders and developers in this article's examples are illustrative composites based on common scenarios.
What a Laravel Directory Website Needs
Before writing code, identify the features each type of user needs:
Visitors | Listing owners | You (admin) |
|---|---|---|
Browse by category and city | Submit a listing | Approve or reject listings |
Search with filters | Claim an existing listing | Moderate reviews |
Search nearby using map radius | Edit their own listing | Manage categories and cities |
Read and write reviews | Pay to feature a listing | View revenue and reports |
If you've built a property website, much of this will feel familiar. Our real estate app guide uses similar listing, filtering, and mapping patterns.
Step 1: Design the Directory Data Model
Five tables cover most directory websites:
categories: Name, slug, and
parent_idfor subcategories.cities: Name, slug, country, latitude, and longitude.
listings: The businesses themselves.
reviews: Ratings and text, with one review per user per listing.
listing_claims: Requests from owners who want to manage an existing listing.
The listings migration carries most of the weight:
// database/migrations/xxxx_create_listings_table.php
Schema::create('listings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); // owner, null until claimed
$table->foreignId('category_id')->constrained();
$table->foreignId('city_id')->constrained();
$table->string('name');
$table->string('slug')->unique();
$table->text('description');
$table->string('address')->nullable();
$table->decimal('lat', 10, 7)->nullable();
$table->decimal('lng', 10, 7)->nullable();
$table->string('website')->nullable();
$table->string('phone')->nullable();
$table->string('status')->default('pending')->index(); // pending, published, rejected
$table->timestamp('featured_until')->nullable()->index();
$table->timestamps();
$table->fullText('description'); // used by Scout's database engine (MySQL/PostgreSQL)
});In the model, cast featured_until to datetime and list the editable columns in $fillable.
Two design choices matter here. Every listing starts as pending, so spam doesn't reach the public site. And featured status is stored as a date, not a boolean, allowing paid placements to expire automatically without a scheduled task to flip a flag.
For reviews, add a unique index on ['listing_id', 'user_id'] so each user can review a listing only once.
Want this generated for you? StackMivo builds a complete directory with models, migrations, search, a Filament admin panel, and authentication from a plain-English description such as "a directory of dog-friendly cafes by city."
Step 2: Add Search with Laravel Scout
Search is at the heart of any directory. Laravel Scout adds full-text search to Eloquent models with a single trait and supports several search engines. See the Scout documentation.
Engine | Best for |
|---|---|
Database (MySQL, PostgreSQL) | Getting started quickly without an additional service; searches your tables directly |
Meilisearch | Typo tolerance, filters, and map-radius search; available self-hosted or in the cloud |
Typesense | Similar capabilities to Meilisearch, including keyword, geographic, and vector search |
Algolia | Fully hosted search priced by usage |
Start with the database engine. It requires no extra infrastructure, and you can switch engines later with minimal changes to your search calls.
// app/Models/Listing.php
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Searchable;
class Listing extends Model
{
use Searchable;
#[SearchUsingFullText(['description'])] // needs a full-text index on description
public function toSearchableArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
];
}
}Then search, filter, and paginate:
$listings = Listing::search($request->string('q'))
->where('status', 'published')
->where('city_id', $city->id)
->paginate(20);One important detail: Scout's shouldBeSearchable() method doesn't apply to the database engine. Always filter by status in your query, as shown above, to prevent pending listings from appearing in search results.
For a deeper engine comparison, see our Laravel Scout with Meilisearch and Typesense guide.
Step 3: Add "Near Me" Search with Meilisearch
Visitors expect to find businesses near their location. Meilisearch supports geographic search through a special _geo field. Add it to your searchable array and cast the coordinates to numbers.
Any field you filter on with where(), such as status and city_id from Step 2, must also be indexed.
public function toSearchableArray(): array
{
$data = [
'id' => (int) $this->id,
'name' => $this->name,
'description' => $this->description,
'status' => $this->status,
'category_id' => (int) $this->category_id,
'city_id' => (int) $this->city_id,
];
// Skip _geo when coordinates are missing, or the listing lands at 0,0
if ($this->lat !== null && $this->lng !== null) {
$data['_geo'] = ['lat' => (float) $this->lat, 'lng' => (float) $this->lng];
}
return $data;
}
// Only index live listings (third-party engines honour this)
public function shouldBeSearchable(): bool
{
return $this->status === 'published';
}Declare the filterable and sortable fields in config/scout.php, then run php artisan scout:sync-index-settings:
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
Listing::class => [
'filterableAttributes' => ['status', 'category_id', 'city_id', '_geo'],
'sortableAttributes' => ['_geo'],
],
],
],Finally, pass a radius filter through Scout's engine callback:
use Meilisearch\Endpoints\Indexes;
// Validate first: lat/lng numeric and in range, category must exist
$lat = (float) $validated['lat'];
$lng = (float) $validated['lng'];
$categoryId = (int) $validated['category_id'];
$listings = Listing::search($validated['q'] ?? '', function (Indexes $meilisearch, string $query, array $options) use ($lat, $lng, $categoryId) {
// Casting above keeps user input from injecting extra filter syntax
$geo = sprintf('category_id = %d AND _geoRadius(%F, %F, 10000)', $categoryId, $lat, $lng);
// Keep any filter Scout already built (where clauses, soft deletes)
$options['filter'] = empty($options['filter']) ? $geo : "({$options['filter']}) AND {$geo}";
$options['sort'] = [sprintf('_geoPoint(%F, %F):asc', $lat, $lng)];
return $meilisearch->search($query, $options);
})->paginate(20);This returns listings within 10 km, ordered by distance from the search location. See Meilisearch's geosearch documentation for bounding-box and polygon options.
Ana launched a directory of physiotherapy clinics in Porto using the database engine. Within two months, visitors were searching by neighbourhood and misspelling clinic names. She moved to Meilisearch in an afternoon, added the _geo field, and "clinics near me" became the site's most-used feature.
Step 4: Build Listing Submission and Moderation
User-generated content is a directory's biggest asset and its biggest risk. Build the submission flow defensively:
Validate with a FormRequest: Require a name, category, and city. Restrict
websiteto HTTP and HTTPS URLs (Blade escaping doesn't preventjavascript:links in anhrefattribute), and set length limits fordescription.Rate-limit submissions using Laravel's
throttlemiddleware, such as five submissions per hour per user.Require a verified email before users can submit listings or reviews by using the
verifiedmiddleware.Default to
pendingand notify the admin queue.Escape all output. Blade's
{{ }}syntax does this by default. Never render listing descriptions with{!! !!}unless you sanitize the HTML first.
For the admin interface, a Filament resource can give you a moderation table with "Approve" and "Reject" actions. Our guide to AI-generated Filament admin panels explains how.
Then protect listing edits with a policy so only the listing's owner or an admin can change it:
// app/Policies/ListingPolicy.php
public function update(User $user, Listing $listing): bool
{
return $user->id === $listing->user_id;
}Our Laravel policies and gates guide covers admin overrides using before().
Claiming a Listing
Many directories start with listings imported from public data, then let business owners claim them. A simple, secure claim flow works like this:
The owner clicks "Claim this listing" and creates a
listing_claimsrow.You verify ownership, for example by emailing a verification link to an address on the business's own website domain or by conducting a manual review.
Once approved, set
listings.user_idto the owner's user ID.
Never transfer ownership based on a claim request alone. Claim hijacking is a common form of abuse on directory websites.
Step 5: Add Reviews and Ratings
Reviews provide fresh content and help build trust. Keep the system simple:
Allow one review per user per listing using the unique index from Step 1.
Add a
statuscolumn so reviews can be moderated like listings.Store the average rating and review count on the listing. Update them in a queued job when a review is approved so listing pages don't need to recalculate them on every view.
Step 6: Monetize with Featured Listings
Most profitable directories combine several revenue streams:
Model | How it works | Best for |
|---|---|---|
Featured listings | A one-time fee to pin a listing to the top for 30 days | Most directories; easiest to start |
Business subscriptions | Monthly plans for extra photos, analytics, and lead forms | Directories with engaged owners |
Lead fees | Charge per enquiry sent to a business | High-value niches, such as legal services and trades |
Sponsorships and ads | Category sponsorships or banner placements | High-traffic directories |
Featured listings are often the quickest way to start earning revenue. With Laravel Cashier, send the owner to Stripe Checkout with the listing ID in the session metadata:
// Owner must be authorized to update this listing
Gate::authorize('update', $listing);
return $request->user()->checkout(
[config('services.stripe.featured_price') => 1], // one-time Stripe price ID
[
'success_url' => route('listings.show', $listing) . '?featured=1',
'cancel_url' => route('listings.show', $listing),
'metadata' => ['listing_id' => $listing->id],
],
);Don't mark a listing as featured on the success URL, because anyone can visit that URL. Instead, listen for Stripe's checkout.session.completed webhook through Cashier's WebhookReceived event:
// app/Listeners/MarkListingFeatured.php
use App\Models\FeaturedPurchase;
use App\Models\Listing;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Events\WebhookReceived;
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] !== 'checkout.session.completed') {
return;
}
$session = $event->payload['data']['object'];
if (($session['payment_status'] ?? null) !== 'paid') {
return;
}
// Stripe can deliver the same event more than once: process each session once
if (FeaturedPurchase::where('stripe_session_id', $session['id'])->exists()) {
return;
}
$listing = Listing::find($session['metadata']['listing_id'] ?? null);
if (! $listing) {
return;
}
// Extend from the current end date if still featured, otherwise from now
$base = $listing->featured_until?->isFuture() ? $listing->featured_until->copy() : now();
DB::transaction(function () use ($listing, $session, $base) {
// stripe_session_id has a unique index, so a racing duplicate fails here
FeaturedPurchase::create(['listing_id' => $listing->id, 'stripe_session_id' => $session['id']]);
$listing->update(['featured_until' => $base->addDays(30)]);
});
}FeaturedPurchase is a small model with a unique stripe_session_id column that also serves as a purchase log.
Note that php artisan cashier:webhook doesn't subscribe to checkout.session.completed by default, so add that event to your endpoint in the Stripe dashboard. Our Cashier subscription billing guide covers webhook setup and recurring plans for business owners.
Then sort featured listings first using orderByRaw('(featured_until IS NOT NULL AND featured_until > ?) desc', [now()]) in database queries. The IS NOT NULL condition prevents PostgreSQL from sorting null values first. Alternatively, display a separate "Featured" section above the search results.
Step 7: Optimize Your Laravel Directory Website for SEO
Most directory traffic comes from long-tail searches such as "plumbers in Austin" or "vegan cafes Lisbon." Build pages that match those searches:
Create category and city landing pages at clean URLs such as
/plumbers/austin, each with a unique introductory paragraph.Avoid thin pages. Add
noindexto category and city combinations with very few listings so you don't flood Google with nearly empty pages.Add LocalBusiness structured data in JSON-LD to each listing page, including the name, address, geographic coordinates, and aggregate rating.
Create an XML sitemap containing published listings and indexable landing pages, and regenerate it on a schedule.
Keep pages fast. Eager-load relationships using
with(['category', 'city']), cache landing pages, and paginate results.
Leon, a freelancer in Manchester, built a directory of independent bike repair shops. He generated 140 city pages but added noindex to every page with fewer than three listings. Only 38 pages were indexed at launch, yet they ranked within weeks. He gradually made the remaining pages available for indexing as the directory grew.
How AI Speeds Up a Laravel Directory Build
Building everything above by hand can take one to three weeks of focused work. An AI app builder can reduce the time spent on boilerplate:
Describe the directory: "A directory of co-working spaces by city, with search, maps, reviews, owner claims, and paid featured listings."
Generate a working Laravel app: Get migrations, models, Scout search, policies, a Filament admin panel, and tests.
Customize what makes it yours: Add niche-specific fields, landing page copy, and pricing.
Deploy to Laravel Cloud, Forge, or your own server.
If you're considering a two-sided platform with payments between users, our guide to building a marketplace app with AI covers additional requirements, such as payouts and commissions.
Launch your directory this week. Describe your niche in plain English and get a Laravel directory with search, moderation, and featured listings. Start free with StackMivo →
Laravel Directory Website Launch Checklist
Listings, reviews, and claims default to
pending.Public queries always filter for
status = published.Submissions require a verified email and are rate-limited.
Policies protect listing edits and featured purchases.
Featured status is set only by a verified, idempotent Stripe webhook.
The search engine indexes only published listings.
Thin category and city pages are marked
noindex.LocalBusiness schema and an XML sitemap are in place.
Database backups and error monitoring are configured.
Frequently Asked Questions
Can you build a directory website with Laravel?
Yes. Laravel can handle every part of a directory website: Eloquent for listings and categories, Scout for search, Cashier for payments, policies for ownership, and Filament for the admin panel.
Is Laravel better than WordPress for a directory site?
WordPress directory themes are faster to launch for simple websites. Laravel is a better fit when you need custom search, map-radius queries, claim flows, tiered plans, or strict moderation because you control the application code.
Which search engine should I use for a Laravel business directory?
Start with Scout's database engine on MySQL or PostgreSQL. Move to Meilisearch or Typesense when you need typo tolerance, faceted filters, or "near me" geographic search.
How do directory websites make money?
Common revenue models include featured listings, business subscriptions, lead fees, and sponsorships. Featured listings through Stripe Checkout are a straightforward way to start.
How long does it take to build a directory site in Laravel?
A basic directory can take one to three weeks to build by hand. An AI app builder can generate the models, search, and admin panel to help you create a working first version in a day, leaving more time for content and niche-specific features.
Conclusion
A Laravel directory website gives you what off-the-shelf themes often can't: search tailored to your niche, moderation you control, and monetization that can grow with your business.
Start with a clean data model and Scout's database engine. Moderate everything, charge for featured listings through a verified webhook, and invest in category and city pages for SEO.
The code is the easy part. Your niche, data, and listings are what make a directory valuable. Get the build done efficiently and spend your time on those priorities.
Build your Laravel directory with StackMivo, free to start →