Build a Google Maps Location Tracker with Laravel 12 and Vue 3
1. Prerequisites
PHP 8.2+, Composer
Node.js 18+ and npm
A database (MySQL/SQLite — SQLite is fine for following along)
A Google Maps API key (see Step 2)
2. Get a Google Maps API key
Go to the Google Cloud Console.
Create a project (or select an existing one).
Enable these APIs: Maps JavaScript API, Places API (for address search), Geocoding API.
Go to APIs & Services → Credentials → Create Credentials → API Key.
Restrict the key: under Application restrictions, set HTTP referrers to your domain(s) (e.g. localhost:5173/*, yourdomain.com/*). Under API restrictions, limit it to the 3 APIs above.
Copy the key — you'll need it in Step 5.
Billing must be enabled on the Google Cloud project even for free-tier usage.
3. Create the Laravel 12 project with the Vue starter kit
Laravel 12's official starter kits scaffold Vue 3 + Inertia + Vite out of the box, which saves you from wiring up a separate SPA and CORS/auth by hand.
composer create-project laravel/laravel google-maps-app cd google-maps-app # Install the Vue starter kit (installs Inertia, Vue 3, Vite, Tailwind) php artisan install:api # optional, if you also want a token-based API composer require laravel/breeze --dev php artisan breeze:install vue
If you're using laravel new via the Laravel installer instead, you can pick "Vue" directly when prompted for a starter kit — same result.
Install JS dependencies and confirm the dev server runs:
npm install npm run dev
In another terminal:
php artisan migrate php artisan serve
Visit http://localhost:8000 — you should see the default Breeze/Vue welcome or login page.
4. Database: locations table + model
Create a migration:
php artisan make:model Location -mcr
This gives you Location.php, a migration, a controller, and a resource-style route stub.
database/migrations/xxxx_create_locations_table.php
public function up(): void
{
Schema::create('locations', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
$table->timestamps();
});
}
Run it:
php artisan migrate
app/Models/Location.php
class Location extends Model
{
protected $fillable = ['name', 'description', 'lat', 'lng'];
protected $casts = [
'lat' => 'float',
'lng' => 'float',
];
}
5. API routes and controller
routes/api.php
use App\Http\Controllers\LocationController;
Route::apiResource('locations', LocationController::class);
Make sure bootstrap/app.php has the API routes registered (Laravel 12 does this by default if you ran php artisan install:api; otherwise add):
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
app/Http/Controllers/LocationController.php
namespace App\Http\Controllers;
use App\Models\Location;
use Illuminate\Http\Request;
class LocationController extends Controller
{
public function index()
{
return Location::orderByDesc('id')->get();
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'lat' => 'required|numeric|between:-90,90',
'lng' => 'required|numeric|between:-180,180',
]);
$location = Location::create($validated);
return response()->json($location, 201);
}
public function destroy(Location $location)
{
$location->delete();
return response()->noContent();
}
}
Quick test:
curl http://localhost:8000/api/locations
Should return [] (empty array) since the table is fresh.
6. Install the Google Maps package for Vue 3
npm install vue3-google-map
7. Add the API key to .env
.env
VITE_GOOGLE_MAPS_API_KEY=your_api_key_here
Vite only exposes env vars prefixed with VITE_ to client-side code — this is intentional and required.
8. Build the Map component
resources/js/Components/LocationMap.vue
<script setup>
import { ref, onMounted } from 'vue'
import { GoogleMap, AdvancedMarker, InfoWindow } from 'vue3-google-map'
import axios from 'axios'
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY
const center = ref({ lat: 23.8103, lng: 90.4125 }) // default: Dhaka
const locations = ref([])
const selected = ref(null)
const newLocationName = ref('')
const pendingClick = ref(null)
async function fetchLocations() {
const { data } = await axios.get('/api/locations')
locations.value = data
}
function handleMapClick(event) {
pendingClick.value = {
lat: event.latLng.lat(),
lng: event.latLng.lng(),
}
}
async function saveLocation() {
if (!pendingClick.value || !newLocationName.value) return
const { data } = await axios.post('/api/locations', {
name: newLocationName.value,
lat: pendingClick.value.lat,
lng: pendingClick.value.lng,
})
locations.value.unshift(data)
newLocationName.value = ''
pendingClick.value = null
}
async function removeLocation(id) {
await axios.delete(`/api/locations/${id}`)
locations.value = locations.value.filter((l) => l.id !== id)
selected.value = null
}
onMounted(fetchLocations)
</script>
<template>
<div class="space-y-4">
<!-- Add-marker form, shown after a map click -->
<div v-if="pendingClick" class="flex gap-2 items-center p-3 border rounded-md bg-gray-50">
<span class="text-sm text-gray-600">
{{ pendingClick.lat.toFixed(5) }}, {{ pendingClick.lng.toFixed(5) }}
</span>
<input
v-model="newLocationName"
type="text"
placeholder="Location name"
class="border rounded px-2 py-1 text-sm flex-1"
/>
<button
@click="saveLocation"
class="bg-blue-600 text-white text-sm px-3 py-1 rounded"
>
Save
</button>
<button
@click="pendingClick = null"
class="text-sm text-gray-500 px-2"
>
Cancel
</button>
</div>
<GoogleMap
:api-key="apiKey"
map-id="DEMO_MAP_ID"
style="width: 100%; height: 500px"
:center="center"
:zoom="12"
@click="handleMapClick"
>
<AdvancedMarker
v-for="loc in locations"
:key="loc.id"
:options="{ position: { lat: loc.lat, lng: loc.lng }, title: loc.name }"
@click="selected = loc"
/>
<InfoWindow
v-if="selected"
:options="{ position: { lat: selected.lat, lng: selected.lng } }"
@closeclick="selected = null"
>
<div class="p-1">
<p class="font-semibold">{{ selected.name }}</p>
<p v-if="selected.description" class="text-sm text-gray-600">
{{ selected.description }}
</p>
<button
@click="removeLocation(selected.id)"
class="text-red-600 text-xs mt-1 underline"
>
Delete
</button>
</div>
</InfoWindow>
</GoogleMap>
</div>
</template>
Key points:
map-id="DEMO_MAP_ID" is required for AdvancedMarker to render (Google's newer marker API needs a Map ID — you can create a real one in Cloud Console under Map Management for custom styling, or use DEMO_MAP_ID for development).
Clicking the map captures lat/lng, then the small form posts it to your Laravel API.
Markers are rendered directly from the locations array fetched from /api/locations.
9. Use the component in a page
resources/js/Pages/Dashboard.vue (or any page)
<script setup>
import LocationMap from '@/Components/LocationMap.vue'
</script>
<template>
<div class="max-w-4xl mx-auto py-8">
<h1 class="text-xl font-semibold mb-4">Locations Map</h1>
<LocationMap />
</div>
</template>
10. Run everything
# Terminal 1 php artisan serve # Terminal 2 npm run dev
Visit the page — click anywhere on the map, name the spot, hit Save, and it persists to the database and reappears on refresh.
11. Optional: address search with Places Autocomplete
If you'd rather type an address than click the map, add an input using the Places library:
npm install @googlemaps/js-api-loader
<script setup>
import { ref, onMounted } from 'vue'
const searchInput = ref(null)
const emit = defineEmits(['place-selected'])
onMounted(() => {
const autocomplete = new google.maps.places.Autocomplete(searchInput.value)
autocomplete.addListener('place_changed', () => {
const place = autocomplete.getPlace()
if (place.geometry) {
emit('place-selected', {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
name: place.name,
})
}
})
})
</script>
<template>
<input ref="searchInput" type="text" placeholder="Search an address..." class="border rounded px-3 py-2 w-full" />
</template>
Make sure libraries: ['places'] is passed as a prop to the GoogleMap component (:libraries="['places']") so the Places library loads alongside the map.




