Managing Geolocation in Laravel Eloquent with MySQL’s Point Data Type

When it comes to storing and working with geographical coordinates (GPS-based longitude and latitude) in MySQL, many solutions you’ll find online can feel outdated. Although other databases—like MariaDB or PostgreSQL with PostGIS—offer more advanced or comprehensive spatial features, this post focuses on MySQL because it’s a requirement for the project I’m currently working on. The approach you’ll see here is my personal discovery — it’s not perfect and can definitely be improved. Still, I’m sharing it so you can learn from my process and use it as a starting point if you’re also tied to MySQL.


MySQL Point Property

When working with geospatial data in MySQL, you’ll often use a Point column type. This type represents a single location defined by its coordinates. MySQL also provides functions like ST_Latitude() and ST_Longitude() to read these coordinates directly.

Laravel’s schema builder gives you a convenient geography method on the Blueprint class. You can use it to create a column of type Point specifically for storing geographical locations. Suppose we have a stores table for location data. The migration might look like this:

<?php
Schema::create('stores', function (Blueprint $table) {
    $table->id();
    $table->geography(column: 'coordinates', subtype: 'point')->nullable();
});

This allows us to store location data in a MySQL column designed for GPS coordinates.

Using Eloquent

Next, we need to bridge the gap between storing coordinates in MySQL and working with them in Eloquent models. MySQL offers functions like ST_SRID() to assign a Spatial Reference System Identifier (SRID) to your coordinates. Using SRID 4326 for GPS coordinates is standard practice.

For example, here’s how you’d set a point value directly in MySQL:

SET @g1 = ST_PointFromText('POINT(4.895168, 52.370216)', 4326);

In Laravel, you can handle this neatly by adding a mutator to your Eloquent model. Mutators let you transform values before they’re inserted into the database. This ensures that when you assign ['long' => X, 'lat' => Y], your model translates it into a proper Point with the correct SRID:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class Store extends Model
{
    public function coordinates(): Attribute
    {
        return Attribute::set(function (array $value) {
            return DB::raw("ST_SRID(Point({$value['long']}, {$value['lat']}), 4326)");
        });
    }
}

Now you can create a Store record using Eloquent like this:

Store::query()->create([
    'coordinates' => [
        'long' => 4.895168,
        'lat'  => 52.370216,
    ],
]);

You could make this even smoother, for example by introducing value objects. Feel free to refine this setup to suit your style.

Reading the coordinates back from the database is a bit trickier. The Point data is stored in binary form, so directly accessing $store->coordinates won’t be very readable. Instead, use MySQL’s ST_Longitude() and ST_Latitude() functions. By adding Eloquent accessors, you can fetch these values in a clean, readable way:

<?php

namespace App\Models;

class Store extends Model
{
    // ...

    public function longitude(): Attribute
    {
        return Attribute::get(function () {
            return (float) $this->query()
                ->whereKey($this)
                ->selectRaw('ST_Longitude(`coordinates`) as longitude')
                ->toBase()
                ->soleValue('longitude');
        });
    }

    public function latitude(): Attribute
    {
        return Attribute::get(function () {
            return (float) $this->query()
                ->whereKey($this)
                ->selectRaw('ST_Latitude(`coordinates`) as latitude')
                ->toBase()
                ->soleValue('latitude');
        });
    }
}

Now you can read the longitude and latitude directly from the Store model:

// Create a Store model first.
$store = Store::query()->create([
    'coordinates' => [
        'long' => 4.895168,
        'lat'  => 52.370216,
    ],
]);

// Read the longitude and latitude properties.
$store->longitude; // 4.895168
$store->latitude;  // 52.370216

However, every time you read one of these properties, Eloquent performs a database query. To optimize this, you can cache the result so it only queries once. You achieve this by calling shouldCache() on the Attribute:

<?php

namespace App\Models;

class Store extends Model
{
    public function longitude(): Attribute
    {
        return Attribute::get(function () {
            // ...
        })->shouldCache();
    }

    public function latitude(): Attribute
    {
        return Attribute::get(function () {
            // ...
        })->shouldCache();
    }
}

This improves performance by reducing redundant queries. However, one issue remains — the N+1 query problem.

Protecting Against N+1

The N+1 query problem occurs when your application makes multiple queries in a loop instead of retrieving all the needed data at once. Even with caching, when looping through a collection of Store models, the first access to each property can still trigger a database query:

foreach (Store::query()->get() as $store) {
    $store->longitude; // Query executed!
    $store->latitude;  // Query executed!
}

This can significantly slow down your application, especially with larger datasets. The best solution is to avoid the problem from the start.

Laravel allows you to operate Eloquent in strict mode by calling Model::shouldBeStrict() in your AppServiceProvider. This throws exceptions when a relation is accessed lazily, helping you detect and fix N+1 issues early.

You can implement this check in your property accessors:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\MissingAttributeException;

class Store extends Model
{
    // ...

    public function longitude(): Attribute
    {
        return Attribute::get(function (?string $value) {
            if ($value === null) {
                // If this model was not recently created and the model prevents lazy loading,
                // throw an exception, since the property should have been eager loaded.
                if (!$this->wasRecentlyCreated && $this->preventsLazyLoading) {
                    throw new MissingAttributeException($this, 'longitude');
                }

                // Otherwise, fetch the value now.
                $value = $this->query()
                    ->whereKey($this)
                    ->selectRaw('ST_Longitude(`coordinates`) as longitude')
                    ->toBase()
                    ->soleValue('longitude');
            }

            return (float) $value;
        })->shouldCache();
    }
}

Eager Loading Eloquent Properties

Laravel makes it easy to eager load relations, thus avoiding N+1 issues for relational data. But what about properties like longitude and latitude, which aren’t actual columns?

You can “eager load” these computed values using subselects. This way, longitude and latitude are included in the initial query, so no extra queries run later.

First, create a custom Eloquent builder and add a method to include the subselects:

<?php

namespace App\Models\Builders;

use Illuminate\Contracts\Database\Query\Builder as Query;
use Illuminate\Database\Eloquent\Builder;

final class StoreBuilder extends Builder
{
    public function withLongLat(): self
    {
        return $this->addSelect([
            'longitude' => fn (Query $q) => $q->selectRaw('ST_Longitude(`coordinates`)'),
            'latitude'  => fn (Query $q) => $q->selectRaw('ST_Latitude(`coordinates`)'),
        ]);
    }
}

Next, tell the Store model to use your custom builder:

<?php

namespace App\Models;

use App\Models\Builders\StoreBuilder;

class Store extends Model
{
    protected static string $builder = StoreBuilder::class;
    // ...
}

Now you can do:

$store = Store::query()->withLongLat()->first();

This preloads the coordinates, so accessing $store->longitude or $store->latitude requires no additional queries.

You could also add scopes directly to your model class. While scopes are a great way to encapsulate reusable query logic, I prefer keeping query logic separate from the model to maintain a cleaner separation of concerns. Feel free to choose the approach that works best for your project.


Conclusion

This tutorial showed one approach to managing geolocation data in Laravel’s Eloquent models using MySQL’s spatial capabilities. While I chose to use custom builders and subselects for eager loading, you might prefer a different approach — such as creating database views or using computed columns, if supported by your MySQL version. These alternatives can sometimes simplify the logic and reduce complexity. No matter which route you take, I recommend writing automated tests before deploying to production. With thoughtful refinement and thorough testing, you can adapt these concepts to fit your application’s unique constraints and performance requirements.