A Laravel real estate website comes down to five moving parts: a normalized property schema, a filterable search layer, role based access for agents and admins, reliable media handling, and a lead path that never drops an enquiry. Get those right and the rest is styling.

Most tutorials jump straight to Blade templates. That is why so many property sites feel quick at launch and crawl two years later, when the filter page fires six subqueries against an unindexed table and the photos sit in a JSON column nobody can migrate. The hard part of a property listing site is the data layer, not the card grid on the homepage.

You already know Laravel ships with Eloquent, migrations, Policies, queues, and Scout. This guide wires them into a working property portal in build order, and every section includes the code.

Key TakeawaysA Laravel real estate website needs five tables from day one, properties, addresses, media, amenities, and enquiries. Cramming 40 columns into one properties table is what forces a rewrite later.Filter with chained Eloquent scopes plus composite indexes on status, type, and price. Move to Laravel Scout only when typo tolerant free text search becomes the real bottleneck.Authorization belongs in Policies, not controllers. An agent edits their own listings, an admin approves them, and a guest sees published listings only.Queue image conversions and geocoding so a 40 photo upload never blocks the request cycle.StackMivo generates the models, migrations, Policies, FormRequests, and Pest tests for this build as production-ready Laravel apps you own and can edit.

What a Laravel real estate website actually needs

Strip away the theme and every Laravel real estate website is four workflows. Agents publish listings. Buyers search them. Both sides exchange enquiries. An admin keeps the data honest. Each workflow maps to a slice of the schema, and those slices are far easier to keep separate than to pull apart later.

Entity

What it holds

Why it stays separate

properties

Price, status, type, bedrooms, area, agent

Read and filtered on every search request

addresses

Street, city, postcode, latitude, longitude

Map bounds and radius queries need their own indexes

media

Photos, floor plans, brochures

One listing has many files, and files change more often than listings

amenities

Pool, parking, elevator, furnished

Many to many, and buyers filter on combinations of them

enquiries

Buyer message, listing, assigned agent

This is the revenue event, so it gets its own lifecycle

Two decisions are worth making now. First, whether listings belong to one agency or many, because multi tenancy is cheap in the first migration and expensive in month six. Second, whether you charge commission, which turns the project into a two sided Laravel marketplace with payouts and verification layered on top of the listing flow.

Want that structure scaffolded before you finish your coffee? Get started free and describe the listing model in plain English.

Design the property listing schema before you write a controller

The properties table carries only what you filter, sort, and show in a result card. Descriptive fields that vary by listing type belong elsewhere. Here is a migration shaped by that rule.

Schema::create('properties', function (Blueprint $table) {
 $table->id();
 $table->foreignId('agent_id')->constrained('users');
 $table->string('slug')->unique();
 $table->string('title');
 $table->text('description')->nullable();
 $table->string('type');
 $table->string('status')->default('draft');
 $table->unsignedBigInteger('price');
 $table->unsignedTinyInteger('bedrooms')->default(0);
 $table->unsignedTinyInteger('bathrooms')->default(0);
 $table->unsignedInteger('area_sqft')->nullable();
 $table->timestamp('published_at')->nullable();
 $table->timestamps();

 $table->index(['status', 'type', 'price']);
 $table->index(['status', 'published_at']);
});

Two choices in there are deliberate. Type and status are plain string columns cast to backed enums in the model, which avoids altering a native enum column every time an agency invents a new listing state. The composite indexes match the two queries the site runs constantly, a filtered public search and the newest published listings.

Keep money, status, and coordinates in the right column types

Store price as an integer in minor units and format it in the view. Floats round badly, and most currency bugs you will ever chase start with a decimal column. Latitude and longitude go on the addresses table as decimal(10, 7), precise enough for any map and still indexable for bounding box queries.

class Property extends Model
{
 protected function casts(): array
 {
 return [
 'type' => PropertyType::class,
 'status' => ListingStatus::class,
 'price' => 'integer',
 'published_at' => 'datetime',
 ];
 }

 public function agent(): BelongsTo
 {
 return $this->belongsTo(User::class, 'agent_id');
 }

 public function address(): HasOne
 {
 return $this->hasOne(Address::class);
 }

 public function amenities(): BelongsToMany
 {
 return $this->belongsToMany(Amenity::class);
 }
}

The casts() method arrived in Laravel 11. On Laravel 9 and 10, declare the same array on the $casts property instead. Typed relationship methods, documented in the Laravel Eloquent relationships guide, are what keep your API Resources, Filament admin, and tests in sync as the domain grows.

Make search and filtering fast on a Laravel real estate website

Search is where property portals fail in public. A buyer picks a city, three amenities, and a price band, then waits. Keep the filter logic in one scope on the model so the web controller, the API, and the map endpoint all share it.

public function scopeFilter(Builder $query, array $filters): Builder
{
 return $query
 ->when($filters['city']?? null, fn ($q, $city) => $q->whereHas(
 'address', fn ($a) => $a->where('city', $city)
 ))
 ->when($filters['min_price']?? null, fn ($q, $min) => $q->where('price', '>=', $min * 100))
 ->when($filters['max_price']?? null, fn ($q, $max) => $q->where('price', '<=', $max * 100))
 ->when($filters['bedrooms']?? null, fn ($q, $beds) => $q->where('bedrooms', '>=', $beds))
 ->when($filters['amenities']?? null, fn ($q, $ids) => $q->whereHas(
 'amenities', fn ($a) => $a->whereIn('amenities.id', $ids), '=', count($ids)
 ));
}

Look at the last argument on the amenities whereHas call. It counts matches, so selecting pool and parking returns listings that have both rather than either, which is what buyers expect and what most tutorials get wrong.

Calling it stays short.

$listings = Property::query()
 ->where('status', ListingStatus::Published)
 ->filter($request->validated())
 ->with(['address', 'agent:id,name', 'media'])
 ->orderByDesc('published_at')
 ->paginate(24)
 ->withQueryString();

Three habits keep that query honest as inventory grows.

  • Index for the filters you ship. A composite index on status, type, and price covers most search traffic. Add one on the addresses city column, since city is the first filter nearly everyone touches.

  • Eager load everything the card renders. A 24 result page that lazy loads the agent and the cover photo fires dozens of extra queries instead of three.

  • Cache the facet counts. The numbers beside each filter option are expensive and change slowly. A five minute cache is invisible to buyers and saves the database.

A two person team at a regional brokerage launched with a few hundred listings and a filter page that felt instant. Two years and several thousand listings later, the same page took long enough that buyers bounced before results rendered. Nothing in the code had changed. The cause was a whereHas on every filter with no supporting index, plus lazy loaded agent records inside the result loop. One composite index and an eager load fixed it in an afternoon, after a month of blaming the hosting.

When buyers start typing neighbourhood names and misspelling them, move free text search to Laravel Scout with Meilisearch or Typesense and keep structured filters in SQL. Running both is normal. Scout handles words, Eloquent handles numbers.

Give agents, admins, and buyers the right level of access

Three roles, three sets of rules. An agent creates and edits their own listings but cannot publish them. An admin reviews the queue and approves. A guest sees published listings only, and never a draft that leaked through a guessed URL.

class PropertyPolicy
{
 public function view(?User $user, Property $property): bool
 {
 return $property->status === ListingStatus::Published
 || $user?->id === $property->agent_id
 || (bool) $user?->isAdmin();
 }

 public function update(User $user, Property $property): bool
 {
 return $user->id === $property->agent_id || $user->isAdmin();
 }

 public function publish(User $user, Property $property): bool
 {
 return $user->isAdmin();
 }
}

Register the policy, then call $this->authorize('update', $property) in the controller or attach can:update,property to the route. Keeping the rules in one class means the web UI, the API, and the Filament admin enforce identical logic, and a Pest test can assert it in four lines. The Laravel authorization documentation covers gates and policy discovery in detail.

Validation deserves the same treatment. A StorePropertyRequest that checks price bounds, enum membership, and image counts stops bad data at the door, and it is the file you reuse when the mobile app arrives. Put the published filter in a dedicated published() scope so nobody forgets it on a new endpoint.

Handle photos, maps, and the parts that quietly break

Photos and floor plans without blocking the request

Agents upload 40 images at a time from a phone, sideways, at 8 megabytes each. Accept the upload, store the original, and queue everything else. Spatie Media Library handles collections, conversions, and responsive variants, and its conversions run on the queue by default. Generate a thumbnail, a card image, and a gallery size, serve WebP, and keep the original for the brochure PDF.

One detail saves a support ticket a week. Store an explicit sort order on the media record and let agents reorder it, because the cover photo decides whether a listing gets clicked at all.

Maps and geocoding on a queue

Geocode on save, not on render. A queued job turns the address into coordinates once, caches the result, and flags failures for an admin. Map search then becomes a bounding box query against indexed decimal columns, which is fast without any spatial extension. Add a radius filter later with a haversine calculation in SQL if buyers ask for it.

Enquiries deserve the same care. Store the message first, then queue the notification to the agent, and rate limit the form. A lost enquiry is a lost commission. The same pattern shows up in any two sided build, covered in more depth in this guide on how to build a marketplace app with AI.

Generate a real estate app in Laravel with AI

Everything above is known work. It is also two weeks of typing no client pays extra for. This is the part of a Laravel real estate website worth handing to a generator, because the shape is predictable and the review is quick.

Describing the domain in plain English is enough.

Build a property listing module. A property belongs to an agent, has a status of draft, pending, published, or sold, a price in minor units, bedrooms, bathrooms, area, one address with latitude and longitude, many photos, and many amenities. Agents manage their own listings, admins approve them, and the public sees published listings only.

From a description like that, StackMivo returns standard Laravel you can read and change.

  • Models and migrations for properties, addresses, amenities, and the pivot table, with casts and indexes already in place.

  • Controllers, FormRequests, and API Resources so the public search and the agent dashboard share one validated contract.

  • Policies that encode the agent, admin, and guest rules instead of scattering them across if statements.

  • A Filament admin resource for the approval queue, usually the screen nobody wants to hand build.

  • Pest tests proving an agent cannot publish their own listing and a guest cannot open a draft.

Output follows framework conventions because the model is trained on the Laravel ecosystem rather than generic PHP. That is the practical difference Laravel-native intelligence makes, and it shows up as fewer corrections per generated file. When the module is ready, one-click deployment pushes it to Laravel Cloud, Forge, Ploi, or an SSH server and runs the migrations for you.

A solo developer rebuilding a regional brokerage site started exactly here. They described the listing domain, checked the generated migrations against the agency's real field list, corrected two enum values, and moved on. The boilerplate was finished before lunch. The work that needed judgement, price bands per neighbourhood, lead routing between three offices, and which listings count as featured, still took the rest of the week, which is how it should be.

Be honest about the limits. Generated code is a starting point you own, not a finished product. You still review migrations against the client's field list, verify commission and pricing logic yourself, and write the market specific rules no model can guess. Treat it like a strong junior's pull request. Read it, test it, then ship it. Ready to try it on the schema above? Try StackMivo Now.

Build the schema first and everything else follows

A Laravel real estate website is not a difficult build, it is an unforgiving one. The decisions that hurt are all made in week one. One fat table instead of five lean ones. Prices in floats. Authorization spread across controllers. Image processing on the web request. Get those four right and the site stays fast as inventory grows.

Work in this order. Model properties, addresses, media, amenities, and enquiries separately. Add composite indexes that match the filters you actually ship. Put every access rule in a Policy and cover it with a test. Queue conversions and geocoding. Bring in Scout when free text search, not structured filtering, becomes the bottleneck.

Then choose what you would rather not type. Scaffolding is the cheapest part of a property portal to generate and the most tedious to write by hand, so handing it over leaves you the parts buyers judge you on, map search that feels instant, photos that load on a phone, and enquiries that reach an agent while the buyer is still interested. Write one migration this week and the rest of the build follows it.