PARQORE Native Maps Plugin for NativePHP Mobile#
A real native map view for NativePHP Mobile — MapKit on iOS, Google Maps (Jetpack Compose Maps) on Android — driven entirely from Blade and PHP. The map renders as a native MKMapView/Compose GoogleMap, not an embedded WebView or JS map library.
Overview#
partek/native-maps (Composer package) ships two things:
<native:parqore-map>— a self-closing Blade UI-component element (SuperNative EDGE) that renders the native map itself: camera, markers, polylines, polygons, circles, gestures, appearance, and interaction callbacks are all just Blade attributes and props.NativeMapsfacade — five imperative PHP commands (animateCamera(),moveCamera(),fitCoordinates(),focusMarker(),visibleRegion()) for controlling an already-mounted map from a button press, a Livewire action, or anywhere else outside the declarative render cycle.
Package partek/native-maps, PHP namespace PARTek\NativeMaps, Composer type: "nativephp-ui-plugin" (a SuperNative EDGE UI component, not the plain nativephp-plugin type used by bridge-function-only PARQORE plugins).
What this is not#
This plugin renders a map and lets you place markers, lines, shapes, and move the camera. It explicitly does not provide:
- Turn-by-turn navigation or routing
- Offline map tile downloads
- Paid geocoding or reverse-geocoding (address ↔ coordinate lookup)
- Background location tracking
For the user's own "blue dot" location, the plugin can display it (shows-user-location) once your app has permission — it does not request permission and does not track location in the background. If you need geocoding, routing, or location tracking, those are separate concerns for your app or another plugin.
Installation#
composer require partek/native-mapsphp artisan native:plugin:register partek/native-maps
If you haven't published NativePHP's plugin provider yet:
php artisan vendor:publish --tag=nativephp-plugins-provider
Rebuild after installing (native/manifest changes need a fresh build):
php artisan native:run
Android — Google Maps API key#
The map renders with the Google Maps SDK on Android, which requires a Maps API key from Google Cloud Console (enable the "Maps SDK for Android" API and create a key restricted to your app's package name + SHA-1).
The plugin's manifest entry reads the key from a Gradle manifest placeholder — ${GOOGLE_MAPS_API_KEY} — never a literal key baked into the plugin or committed to source control. Add this to your app's module build.gradle (or build.gradle.kts):
android { defaultConfig { manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = project.findProperty("GOOGLE_MAPS_API_KEY") ?: "" }}
and set the real value in a local, untracked gradle.properties (or an environment-backed equivalent for CI):
# gradle.properties (do not commit)GOOGLE_MAPS_API_KEY=your-google-maps-api-key-here
If the placeholder resolves to an empty string at build/runtime, the Android map renders a sanitized configuration-error state instead of crashing — see Troubleshooting.
iOS — no API key needed#
iOS uses Apple's MapKit, a system framework included with the OS. There is nothing to configure, register, or pay for on iOS — ios.info_plist, swift_packages, and pods are all empty in nativephp.json.
Usage#
Complete example — delivery map#
<native:parqore-map id="delivery-map" :camera="['latitude' => 40.7128, 'longitude' => -74.0060, 'zoom' => 12]" :markers="$drivers" shows-user-location="true" cluster-markers="true" map-type="standard" appearance="system"/>
namespace App\NativeComponents; use Native\Mobile\Attributes\On;use Native\Mobile\Edge\NativeComponent;use PARTek\NativeMaps\Events\MapPressed;use PARTek\NativeMaps\Events\MarkerPressed; class DeliveryMap extends NativeComponent{ public array $drivers = [ ['id' => 'driver-1', 'latitude' => 40.7128, 'longitude' => -74.0060, 'title' => 'Alex', 'color' => '#4F46E5'], ['id' => 'driver-2', 'latitude' => 40.7357, 'longitude' => -74.1724, 'title' => 'Sam', 'color' => '#16A34A'], ]; public ?string $selectedDriverId = null; #[On(MarkerPressed::class)] public function selectDriver(string $mapId, string $markerId): void { if ($mapId !== 'delivery-map') { return; } $this->selectedDriverId = $markerId; } #[On(MapPressed::class)] public function deselectDriver(string $mapId): void { if ($mapId === 'delivery-map') { $this->selectedDriverId = null; } } public function render() { return view('livewire.delivery-map'); }}
See examples/DeliveryMapScreen.php and examples/delivery-map.blade.php for a fuller, commented version (5 drivers, camera fit, marker press handling).
Blade attributes#
| Attribute | Type | Default | Notes |
|---|---|---|---|
id |
string | — | Required. Non-empty. How NativeMaps:: commands address this map instance. |
camera |
array | none | Initial camera. ['latitude' => .., 'longitude' => .., 'zoom' => .., 'bearing' => .., 'pitch' => ..], or ['target' => ['latitude' => .., 'longitude' => ..], 'zoom' => ..]. |
markers |
array | [] |
Array of marker arrays/DTOs. See Markers. |
polylines |
array | [] |
Array of polyline arrays/DTOs. See Overlays. |
polygons |
array | [] |
Array of polygon arrays/DTOs. |
circles |
array | [] |
Array of circle arrays/DTOs. |
map-type |
string | standard |
standard, satellite, hybrid, terrain. An unrecognized value silently falls back to standard rather than crossing an invalid value to native. |
appearance |
string | system |
light, dark, system. Falls back to system if unrecognized. |
min-zoom / max-zoom |
float | none | Passed through as-is to the native renderer (not range-validated in PHP the way camera.zoom is). |
shows-user-location |
bool | false |
Shows the device's location on the map, only if permission has already been granted. See User-location permission. |
cluster-markers |
bool | false |
Enables native marker clustering. See Markers and clustering. |
scroll-enabled |
bool | true |
Pan gesture. |
zoom-enabled |
bool | true |
Pinch-zoom gesture. |
rotate-enabled |
bool | true |
Two-finger rotate gesture. |
tilt-enabled |
bool | true |
Two-finger tilt/pitch gesture. |
padding |
float|array | none | Uniform: padding="16". Per-edge: :padding="['top' => 60, 'bottom' => 24]" (unset edges default to 0). |
selected-marker-id |
string | none | Programmatically shows a marker's callout/info window. |
android-map-style |
string|array | none | Raw Google Maps JSON style (string or array — arrays are json_encoded). Android only — silently ignored by the iOS renderer, MapKit has no equivalent styling API. |
Boolean attributes accept Blade's usual ="true"/="false" strings as well as real PHP booleans.
Events#
Map interactions use NativePHP's supported named native-event channel. Listen with #[On(EventClass::class)] on the active NativeComponent. Every event includes mapId, so a screen with more than one map can filter the event to the intended instance.
| Event | Constructor payload | Fires when |
|---|---|---|
MapPressed |
mapId, latitude, longitude |
The map background is tapped |
MapLongPressed |
mapId, latitude, longitude |
The map background is long-pressed |
MarkerPressed |
mapId, markerId, latitude, longitude |
A marker is tapped |
MarkerDragEnded |
mapId, markerId, latitude, longitude |
A draggable marker is dropped |
CameraChanged |
mapId, latitude, longitude, zoom, bearing, pitch |
The camera moves; this is continuous |
CameraIdle |
mapId |
The camera stops moving |
RegionChanged |
mapId, northEast, southWest |
The camera settles; each bound is {latitude, longitude} |
The payload names match the public event constructor properties exactly on both platforms. NativePHP binds those names to listener parameters:
use Native\Mobile\Attributes\On;use PARTek\NativeMaps\Events\MarkerPressed; #[On(MarkerPressed::class)]public function selectDriver(string $mapId, string $markerId): void{ if ($mapId === 'delivery-map') { $this->selectedDriverId = $markerId; }}
PHP API — DTOs#
Every collection prop (markers, polylines, polygons, circles) and the camera/padding attributes accept either a plain array or the matching DTO — they're interchangeable, and can be mixed within the same array. Constructing a DTO directly gives you immediate PHP-side validation with a clear exception message; passing plain arrays is equivalent, since the element converts every array to the DTO internally via ::from().
| DTO | Required fields | Validation |
|---|---|---|
Coordinate |
latitude, longitude (or shorthand lat/lng) |
latitude −90..90, longitude −180..180 |
CameraPosition |
target (a Coordinate) |
zoom 0–22, bearing 0–360 (exclusive of 360), pitch 0–90 |
Marker |
id, coordinate |
id non-empty and unique within the markers array; color must be a 6- or 8-digit hex string (#RRGGBB / #RRGGBBAA); icon must be a local asset name (A-Za-z0-9_- only — remote icon URLs are not supported in v1) |
Polyline |
id, coordinates (≥ 2) |
id non-empty/unique; strokeWidth 0.5–40; opacity 0–1; strokeColor hex |
Polygon |
id, coordinates (≥ 3, open ring — don't repeat the first point) |
same width/opacity/color rules as Polyline, plus fillColor hex |
Circle |
id, center, radius (meters, > 0) |
radius up to ~20,037,508 m (half of Earth's circumference); same width/opacity/color rules, plus fillColor hex |
MapRegion |
northEast, southWest (both Coordinate) |
northEast latitude must not be south of southWest latitude |
MapPadding |
top, right, bottom, left (all default 0) |
each edge 0–2000 |
All DTOs are readonly and expose ::from(array|self $value): self (accepts either shape) and ->toArray(). Every DTO constructor throws PARTek\NativeMaps\Exceptions\InvalidCoordinateException, InvalidStyleException, or InvalidCollectionException (all InvalidArgumentExceptions) on invalid input, and every check runs before anything crosses the native bridge.
use PARTek\NativeMaps\DTO\Coordinate;use PARTek\NativeMaps\DTO\Marker; $marker = new Marker( id: 'driver-1', coordinate: new Coordinate(40.7128, -74.0060), title: 'Alex', color: '#4F46E5', draggable: false,);
PHP API — imperative commands#
These act on a map that is already mounted by <native:parqore-map>, addressed by its Blade id. They don't render anything themselves — for a one-shot animated camera move triggered from, say, a "center on me" button:
use PARTek\NativeMaps\Facades\NativeMaps; // Animate the camera to a new position.NativeMaps::animateCamera( mapId: 'delivery-map', latitude: 40.7128, longitude: -74.0060, zoom: 15.0, // default 14.0 bearing: 0.0, // default 0.0, degrees 0-360 pitch: 0.0, // default 0.0, degrees 0-90); // bool // Same, but jumps immediately with no animation.NativeMaps::moveCamera('delivery-map', 40.7128, -74.0060, zoom: 15.0); // bool // Animate so every given coordinate is visible, with padding (points, default 32.0).NativeMaps::fitCoordinates('delivery-map', [ ['latitude' => 40.7128, 'longitude' => -74.0060], ['latitude' => 40.7357, 'longitude' => -74.1724],], padding: 48.0); // bool // Animate the camera to center on a specific marker by id.NativeMaps::focusMarker('delivery-map', 'driver-1', zoom: 16.0); // bool, zoom optional // Read the currently visible lat/lng bounding box, when the platform can report it.$region = NativeMaps::visibleRegion('delivery-map'); // ?MapRegion
| Method | Returns | Notes |
|---|---|---|
animateCamera(mapId, latitude, longitude, zoom = 14.0, bearing = 0.0, pitch = 0.0) |
bool |
false if the coordinate is invalid, the map isn't mounted, or native reports an error. Validates the coordinate before crossing the bridge (throws InvalidCoordinateException for out-of-range lat/lng). |
moveCamera(...) |
bool |
Same signature and validation as animateCamera(), no animation. |
fitCoordinates(mapId, coordinates, padding = 32.0) |
bool |
Requires ≥ 1 coordinate; padding must be 0–2000, else throws InvalidCollectionException. |
focusMarker(mapId, markerId, zoom = null) |
bool |
markerId must be non-empty. zoom is optional — omit to keep the current zoom. |
visibleRegion(mapId) |
?MapRegion |
null means "unavailable right now" (map not mounted, command failed, or the platform can't reliably report a region — e.g. mid-animation on some Android versions), not an error. Treat null as "try again later," not as a failure to handle. |
A non-empty mapId is required by every command (throws InvalidCollectionException otherwise). Every command validates its PHP-side input before calling native; a native-side failure (unknown mapId, platform not ready, misconfigured Android Maps key) fails closed and returns false/null rather than throwing, mirroring the rest of the PARQORE portfolio's bridge-call convention.
JavaScript API#
resources/js/index.js mirrors the five PHP commands 1:1 for Vue/React/Inertia apps. There is no JS call to render the map itself — that only happens declaratively through <native:parqore-map>.
import { animateCamera, moveCamera, fitCoordinates, focusMarker, getVisibleRegion } from '../../vendor/partek/native-maps/resources/js/index.js'; await animateCamera({ mapId: 'delivery-map', latitude: 40.7128, longitude: -74.0060, zoom: 15 });await moveCamera({ mapId: 'delivery-map', latitude: 40.7128, longitude: -74.0060 });await fitCoordinates({ mapId: 'delivery-map', coordinates: [{ latitude: 40.7128, longitude: -74.0060 }, { latitude: 40.7357, longitude: -74.1724 }], padding: 48 });await focusMarker({ mapId: 'delivery-map', markerId: 'driver-1', zoom: 16 });const region = await getVisibleRegion({ mapId: 'delivery-map' }); // { north_east, south_west } | null
Each function POSTs to /_native/api/call with an X-CSRF-TOKEN header and throws on an HTTP error or a {status: 'error'} response — matching the standard NativePHP bridge-call contract. A default export bundles all five as NativeMaps.animateCamera(...), etc.
Livewire — reactive markers#
A Livewire component's markers (or polylines/polygons/circles) property can simply change, and the map updates on the next render — no special handling, wire:key trick, or manual diffing needed. The element's props are keyed arrays, and both MapKit's SwiftUI Map and Compose Maps' GoogleMap already diff a keyed content list themselves; Livewire's own render/diff pipeline (plus the framework's frame-diff, which skips sending a frame when nothing actually changed) does the rest.
namespace App\Livewire; use Livewire\Component; class DeliveryMap extends Component{ public array $drivers = []; public function mount(): void { $this->drivers = Driver::query()->get(['id', 'lat', 'lng', 'name']) ->map(fn ($d) => [ 'id' => (string) $d->id, 'latitude' => $d->lat, 'longitude' => $d->lng, 'title' => $d->name, ])->all(); } public function refreshDrivers(): void { // Re-assigning $this->drivers is enough — Livewire re-renders // the component, the map element receives the new markers prop, // and the native side diffs it by marker id. $this->mount(); } public function render() { return view('livewire.delivery-map'); }}
<div> <native:parqore-map id="delivery-map" :markers="$drivers" /></div>
Markers and clustering#
A marker is id (unique, required), a coordinate, and optional title, description, color (hex), icon (local asset name), draggable, selected, and accessibilityLabel.
cluster-markers="true" turns on native clustering:
- iOS — MapKit's own built-in clustering (
MKMarkerAnnotationView.clusteringIdentifier). - Android — the
maps-compose-utilsClusteringcomposable, backed byandroid-maps-utils(both declared as Gradle dependencies innativephp.json).
Clustering is entirely native-side — there's no PHP configuration for cluster radius or thresholds in this version; you pass markers, the platform groups them.
Overlays#
Polylines, polygons, and circles share the same style props:
| Prop | Applies to | Default | Range |
|---|---|---|---|
strokeColor |
all three | #4F46E5 |
6/8-digit hex |
fillColor |
polygon, circle | #4F46E533 |
6/8-digit hex |
strokeWidth |
all three | 4.0 (polyline) / 2.0 (polygon, circle) |
0.5–40 |
opacity |
all three | 1.0 |
0–1 |
'polylines' => [ ['id' => 'route-1', 'coordinates' => [ ['latitude' => 40.7128, 'longitude' => -74.0060], ['latitude' => 40.7357, 'longitude' => -74.1724], ], 'strokeColor' => '#16A34A', 'strokeWidth' => 5],],'circles' => [ ['id' => 'service-area', 'latitude' => 40.7128, 'longitude' => -74.0060, 'radius' => 5000, 'fillColor' => '#4F46E533'],],
User-location permission#
The plugin never requests location permission itself. shows-user-location="true" only shows the device's location layer if permission has already been granted — it does not prompt, and it does not error or crash when permission is absent, it simply omits the "my location" layer. Request permission yourself, before setting the attribute, using core NativePHP's geolocation API:
use Native\Mobile\Facades\Geolocation; Geolocation::requestPermissions();
Call this from wherever your app already handles permission prompts (typically a settings/onboarding screen or right before you first want to show the user's location) — not from inside the map's own render cycle. See Events note above and the Boost guideline for the exact anti-pattern to avoid.
Dark mode#
appearance="light", "dark", or "system" (default). "system" follows the OS appearance setting automatically on both platforms; the plugin does not require you to track or pass the app's own theme state.
Accessibility#
Every marker accepts an accessibilityLabel; when omitted, it falls back to the marker's title (see Marker::toArray(), which sends accessibility_label ?? title to native).
A native map view is inherently harder for a screen-reader user to interact with than a list — panning, zooming, and picking out an individual marker by touch don't have the same discoverability as VoiceOver/TalkBack swiping through a list. This is guidance, not a feature the plugin implements: pair the map with a textual/list alternative for any information that matters (e.g. a scrollable list of the same markers with name + distance, next to or below the map), so a screen-reader user isn't required to use the map itself to get critical information.
Security#
Every value this plugin crosses the native bridge with is validated in PHP first — see src/DTO/* for the exact rules, summarized here:
- Marker icons are constrained, not arbitrary.
Marker::$iconmust match^[A-Za-z0-9_\-]+$— a local asset name only. There is no remote-URL loading path for marker icons in this version, so a hostile or malformediconvalue cannot cause the native side to fetch an arbitrary URL. - Style/color input is validated before it reaches native. Hex colors (
Marker::$color, and everystrokeColor/fillColoron polylines, polygons, and circles) are checked against a strict#RRGGBB/#RRGGBBAApattern before crossing the bridge. - Coordinates and numeric style props are range-checked in PHP (latitude/longitude bounds, zoom/bearing/pitch ranges, stroke width, opacity, radius) — invalid input throws before any native call is made, rather than being passed through for the native renderer to reject or mishandle.
- The Android Google Maps API key is never stored or shipped by this plugin. It's injected at build time through a Gradle manifest placeholder (
${GOOGLE_MAPS_API_KEY}, declared asandroid.meta_datainnativephp.json) that the consuming app supplies from its own untrackedgradle.propertiesor CI secret — the plugin's source and Packagist distribution never contain a real key. - No location permission is requested by this plugin.
shows-user-locationonly reads permission state the host app already obtained through NativePHP's coreGeolocation::requestPermissions()— this plugin has no permission-prompting or background-tracking code path to secure.
See SECURITY.md for the full attack-surface writeup and how to report a vulnerability.
Testing with the fake#
use PARTek\NativeMaps\Facades\NativeMaps; it('animates the camera to a driver', function () { NativeMaps::fake(); NativeMaps::animateCamera('delivery-map', 40.7128, -74.0060, zoom: 15.0); NativeMaps::assertCameraAnimated(fn (array $params) => $params['mapId'] === 'delivery-map');});
NativeMaps::fake() swaps the bound manager for FakeNativeMaps, which records every imperative command instead of crossing the bridge and returns success unless configured otherwise. Returns the fake instance so it can be chained.
| Assertion | Checks |
|---|---|
assertCameraAnimated(?callable $callback = null) |
An animateCamera() call happened; optional callback receives the call's params array to narrow the match. |
assertCameraMoved(?callable $callback = null) |
Same, for moveCamera(). |
assertCoordinatesFitted(?callable $callback = null) |
Same, for fitCoordinates(). |
assertMarkerFocused(?string $markerId = null) |
A focusMarker() call happened; pass a marker id to require that specific marker. |
assertNothingCalled() |
No command was issued at all. |
->failing() (chainable off fake()) makes every subsequent command report failure, as if native rejected it:
$fake = NativeMaps::fake()->failing(); expect(NativeMaps::animateCamera('delivery-map', 1.0, 1.0))->toBeFalse();
The fake does not intercept rendering — markers/camera/overlays passed to <native:parqore-map> are plain PHP array/DTO construction, exercised with ordinary Blade rendering assertions (Native's assertElement()), not NativeMaps::fake().
Troubleshooting#
Android: map shows a "Map not configured" placeholder instead of a map. The Google Play Services Maps SDK does not degrade gracefully on its own — with no key configured it throws IllegalStateException: API key not found synchronously during map view inflation and crashes the whole app (confirmed on-device). MapRenderer.kt checks the com.google.android.geo.API_KEY manifest meta-data itself before ever mounting the GoogleMap composable, and renders this placeholder instead of letting that happen. Fix it by confirming manifestPlaceholders["GOOGLE_MAPS_API_KEY"] actually resolves to a real key in your app's build.gradle (see Installation above) — an empty string reaches the manifest as an empty attribute, which the check treats as unconfigured.
iOS: "terrain" map type looks the same as "standard." Expected — see Platform differences.
Marker icon throws InvalidStyleException. Icons must be a local asset name matching [A-Za-z0-9_-]+ — no file extension, no path, no remote URL. Remote icon URLs are not supported in this version.
Duplicate marker/polyline/polygon/circle ids throw InvalidCollectionException. Every id must be unique within its own collection (markers don't collide with polyline ids, etc. — uniqueness is per-collection, not global).
Platform differences#
| Behavior | iOS (MapKit) | Android (Compose Maps) |
|---|---|---|
map-type="terrain" |
Falls back to .standard — MapKit has no distinct terrain map type. Documented in MapType's own docblock. |
Renders TYPE_TERRAIN natively. |
android-map-style |
Silently ignored — MapKit has no equivalent JSON-styling API. | Applies the Google Maps JSON style. |
padding |
MapKit has no first-class "content padding" concept the way Google Maps does. The renderer applies it via MKMapView.directionalLayoutMargins, which shifts MapKit's own built-in chrome (compass, scale bar, user-location/legal attribution) away from the padded edge — it does not reposition map content itself the way NativeMaps::fitCoordinates()'s padding or Android's contentPadding do. |
Maps directly to Compose Maps' native contentPadding, which does reposition content. |
| Clustering | MapKit's built-in clusteringIdentifier. |
maps-compose-utils' Clustering composable + android-maps-utils. |
min-zoom/max-zoom |
Passed through to the renderer as given — not range-clamped in PHP. | Same. |
Performance#
The Blade element is tested (MapElementTest) with 1,000 markers with no throwing and sub-second resolution. Practical guidance beyond that ceiling:
- Turn on
cluster-markers="true"once you're regularly showing more than a few hundred markers — clustering keeps the map legible and is handled entirely natively (see Markers and clustering). - The element's frame-diff means a render with no actual prop change is cheap, but that's a floor, not a reason to be careless — if your app already knows a large
markers/polylinesarray hasn't changed since the last render, avoid re-sending it on every Livewire tick (e.g. gate the query behind a dirty flag or a#[Computed]that only recalculates when its inputs change) rather than relying on the diff alone to absorb unnecessary work upstream of it.
License#
Proprietary commercial. See LICENSE.