thtc / laravel-static-maps
Server-side static map image generator for Laravel: raster XYZ tile composition, sharp vector markers and POI labels, rendered entirely in PHP without a headless browser.
Requires
- php: ^8.3
- ext-imagick: *
- ext-mbstring: *
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.27
- orchestra/testbench: ^11.2
- pestphp/pest: ^5.1
- pestphp/pest-plugin-laravel: ^5.0
README
Server-side static map image generator for Laravel. Composes raster XYZ tiles into a PNG, draws a sharp vector marker anchored at the requested coordinate and an optional POI label, and caches the result — entirely in PHP, with no headless browser involved.
GET /maps/static/21.543333/39.172778/15.png?imagery=osm&width=800&height=500&marker-color=%23ff0000&marker-size=48&poi-name=Jeddah
Contents
- Features
- Requirements
- Installation
- Configuration
- Imagery sources
- The endpoint
- Query parameters
- POI text styling
- Transparent colours
- Label position
- Fonts
- Examples
- Programmatic use
- Playground
- Caching
- Redis
- Nginx
- Security
- Performance
- How the marker is rendered
- POI labels
- Arabic and other complex scripts
- Image guarantees
- Testing
- Development
- Building the playground assets
- Troubleshooting
- Imagery licensing and attribution
- Design decisions
- Licence
Features
- Pure PHP rendering. Tiles are fetched and composited with Imagick. No Chromium, Puppeteer, Playwright or screenshotting.
- Accurate Web Mercator maths. Proper spherical projection with pole clamping and antimeridian wrapping, verified against reference tile indices from zoom 0 to 22.
- Genuinely vector markers. The marker outline is drawn as vectors at the requested size, so it stays crisp from 16 px to 128 px.
- Correct marker anchoring. The point of the pin lands on the exact centre pixel, not the centre of its bounding box.
- Optional POI labels with UTF-8 support, including Arabic contextual shaping and bidirectional reordering performed in-package when the image library cannot do it.
- Fully styleable labels — text and background colour, font, weight, size, padding, corner radius and position, all bounded by configuration and all part of the cached image's identity.
- Transparency everywhere — the marker and both label colours accept
#RRGGBBAA, with#RRGGBBstill meaning fully opaque. - Fonts ship with the package and are discovered automatically — drop a
.ttfor.otfinto the font directory and it appears in the renderer and the playground, with no registration. - Glyph-aware shaping — a presentation form is only used when the chosen font actually has it, so a font with partial coverage degrades to a legible form instead of silently dropping a letter.
- Two-layer caching. Individual tiles and finished images are both cached through Laravel's cache abstraction.
- HTTP caching.
Cache-Control,s-maxageand strongETags, with conditional requests answered before any work is done. - Bootstrap 5 playground served by the package, requiring no changes to the host application's frontend build.
- Security first. Imagery is referenced by configured key only, colours are strictly validated, limits are enforced, and API keys never reach the log.
Requirements
| Requirement | Version | Notes |
|---|---|---|
| PHP | 8.3+ | |
| Laravel | 12 or 13 | |
ext-imagick |
ImageMagick 6.9+ / 7.x | Required |
ext-mbstring |
— | Required |
Imagick is required rather than optional. It is the only widely available PHP image library that can draw vector paths, which is what keeps the marker sharp at every size — see Design decisions.
No font needs to be installed on the server: one is bundled.
Installation
composer require thtc/laravel-static-maps
That is the whole installation. The service provider is registered through Laravel's package discovery, routes are registered by the provider, the default configuration is merged in, and the playground's assets are served by the package. You do not need to:
- register a service provider or add anything to
bootstrap/providers.php - register routes
- publish the configuration
- publish assets
- touch
vite.config.js,package.json, global CSS or global JS - run
npm install
Publishing the configuration is optional:
php artisan vendor:publish --tag=laravel-static-maps-config
The playground assets and views can be published too, though neither is necessary:
php artisan vendor:publish --tag=laravel-static-maps-assets php artisan vendor:publish --tag=laravel-static-maps-views
Publishing the assets copies them to public/vendor/laravel-static-maps, and the
playground prefers that copy when it exists. This only changes who serves the
two files — PHP or your web server.
Configuration
The package ships its configuration as config/laravel-static-maps.php, read
under the matching key:
config('laravel-static-maps.imagery'); config('laravel-static-maps.defaults'); config('laravel-static-maps.cache');
Publishing it writes config/laravel-static-maps.php into your application, so
the file name, the config key and the package name all agree.
The full default configuration, with the keys most people change first:
return [ 'route' => [ 'enabled' => true, 'prefix' => 'maps/static', 'middleware' => [], 'playground' => [ 'enabled' => true, 'middleware' => [], ], ], 'defaults' => [ 'imagery' => 'thtc-en', 'marker_color' => '#dc3545', 'marker_size' => 48, 'width' => 800, 'height' => 500, ], 'zoom' => [ 'min' => 0, 'max' => 22, 'default' => 15, ], 'limits' => [ 'width' => ['min' => 100, 'max' => 2000], 'height' => ['min' => 100, 'max' => 2000], 'marker_size' => ['min' => 16, 'max' => 128], 'poi_font_size' => ['min' => 8, 'max' => 72], 'poi_padding' => ['min' => 0, 'max' => 32], 'poi_border_radius' => ['min' => 0, 'max' => 32], 'max_tiles' => 100, ], 'label' => [ 'default_font' => 'sans', 'fonts' => [ 'sans' => [ 'label' => 'DejaVu Sans', 'weights' => [ 400 => 'DejaVuSans.ttf', 700 => 'DejaVuSans-Bold.ttf', ], ], ], 'weights' => [400, 500, 600, 700], 'text_color' => '#212529', 'background_color' => '#ffffffd1', 'font_size' => 15, 'font_weight' => 400, 'padding' => 6, 'border_radius' => 4, 'gap' => 6, 'margin' => 6, 'max_length' => 120, 'max_width_ratio' => 0.6, ], 'imagery' => [ 'google-mt0-ar' => [ 'label' => 'Google (Arabic)', 'url' => 'https://mt0.google.com/vt/lyrs=m&hl=ar&gl=sa&x={x}&y={y}&z={z}', ], 'google-mt0' => [ 'label' => 'Google', 'url' => 'https://mt0.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', ], 'google-mt1-ar' => [ 'label' => 'Google Satellite (Arabic)', 'url' => 'https://mt1.google.com/vt/lyrs=s&hl=ar&gl=sa&x={x}&y={y}&z={z}', ], 'google-mt1' => [ 'label' => 'Google Satellite', 'url' => 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', ], 'thtc-en' => [ 'label' => 'THTC (English)', 'url' => env('STATIC_MAPS_THTC_URL', 'https://ksamaps.com/api/raster/{z}/{x}/{y}'), 'key' => env('STATIC_MAPS_THTC_KEY'), ], 'thtc-ar' => [ 'label' => 'THTC (Arabic)', 'url' => env('STATIC_MAPS_THTC_ARABIC_URL', 'https://ksamaps.com/api/rasterarabic/{z}/{x}/{y}'), 'key' => env('STATIC_MAPS_THTC_KEY'), ], 'satellite-thtc' => [ 'label' => 'THTC Satellite', 'url' => env('STATIC_MAPS_THTC_SATELLITE_URL', 'https://ksamaps.com/api/satellite/{z}/{x}/{y}'), 'key' => env('STATIC_MAPS_THTC_KEY'), ], 'osm' => [ 'label' => 'OpenStreetMap', 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', ], ], 'imagery_key_parameter' => 'key', 'cache' => [ 'enabled' => true, 'store' => env('STATIC_MAPS_CACHE_STORE'), 'tiles_ttl' => 86400, 'images_ttl' => 86400, 'prefix' => 'static-maps', 'coordinate_precision' => 6, ], 'http' => [ 'timeout' => 10, 'connect_timeout' => 5, 'user_agent' => 'Laravel Static Maps', 'concurrency' => 8, 'retries' => 1, 'retry_delay' => 100, 'fail_on_tile_error' => false, 'verify' => true, ], 'logging' => [ 'enabled' => true, 'channel' => null, 'redact' => ['key', 'apikey', 'api_key', 'token', 'access_token', 'signature', 'secret'], ], ];
The published file documents every key, including tiles, response and
attribution.
Configuration is merged recursively underneath your own, rather than with
Laravel's usual top-level mergeConfigFrom. That matters when upgrading: a
shallow merge lets a published config file replace a whole nested array, so any
key the package adds later would silently vanish and the feature depending on it
would appear broken with nothing in the log to explain why. Merging recursively
means a published file keeps overriding exactly the values it names while newly
added keys still arrive with their defaults.
Lists — label.weights, label.positions, fonts.directories,
logging.redact — are replaced rather than appended to, so removing an entry
from one actually removes it.
Environment variables
| Variable | Purpose |
|---|---|
STATIC_MAPS_CACHE_STORE |
Dedicated cache store; falls back to the application default |
STATIC_MAPS_THTC_KEY |
THTC imagery API key, appended to tile requests server-side |
STATIC_MAPS_THTC_URL |
THTC raster tile template |
STATIC_MAPS_THTC_ARABIC_URL |
THTC Arabic raster tile template |
STATIC_MAPS_THTC_SATELLITE_URL |
THTC satellite tile template |
Imagery sources
Each imagery source is an array carrying the name to show in the playground and
an XYZ tile URL template using the {z}, {x} and {y} placeholders. Anything
else in the template, including an API key, is passed through untouched.
'imagery' => [ 'my-tiles' => [ 'label' => 'My Tiles', 'url' => 'https://tiles.example.com/v1/{z}/{x}/{y}.png?key=' . env('MY_TILES_KEY'), ], ],
| Key | Required | Purpose |
|---|---|---|
label |
no | Name shown in the playground dropdown |
url |
yes | XYZ tile URL template |
key |
no | Server-side credential — see Credentials |
key_parameter |
no | Query parameter the credential is appended as, overriding the global default |
A source without a label falls back to a readable form derived from its key,
so satellite-thtc shows as "Satellite Thtc". A bare URL string is still
accepted in place of the array, which keeps a minimal hand written entry
working:
'imagery' => [ 'my-tiles' => 'https://tiles.example.com/v1/{z}/{x}/{y}.png', ],
Requests select a source by key:
?imagery=my-tiles
A request can never supply a tile URL. Only keys present in configuration are accepted, and an unknown key is rejected with a 422. This is what stops the endpoint being usable as an SSRF proxy — see Security.
Credentials
A source that needs a key adds one:
'imagery' => [ 'thtc-en' => [ 'label' => 'THTC (English)', 'url' => env('STATIC_MAPS_THTC_URL', 'https://ksamaps.com/api/raster/{z}/{x}/{y}'), 'key' => env('STATIC_MAPS_THTC_KEY'), ], ], // The parameter the key is appended as. A source may override it with a // "key_parameter" of its own. 'imagery_key_parameter' => 'key',
STATIC_MAPS_THTC_KEY=your-key-here
The key is read from the environment and appended to tile requests on the server. It is server-side only, and every path it could otherwise escape through is closed:
| Accepted from a query parameter | Never. ?key=, ?token=, ?api_key= and friends are not part of the API; passing them changes nothing |
| Rendered into a generated URL | Never. Generated URLs name the imagery source by key only |
| Shown in the playground | Never |
| Written to the log | Never. Tile URLs pass through the sanitiser first, which redacts it |
| Included in a cache key | Never. Rotating the key does not invalidate a single cached image |
| Readable from the source object | No. It is a private property, and is not serialised |
A template that already carries the parameter is left alone, so a source configured with its key inline still works and is never given two.
The endpoint
GET {prefix}/{lat}/{lng}/{zoom}.png
With the default prefix:
GET /maps/static/21.543333/39.172778/15.png
| Segment | Range | Notes |
|---|---|---|
lat |
−90 to 90 | Clamped to ±85.05112878 when projecting, since Web Mercator cannot represent the poles |
lng |
−180 to 180 | Wraps correctly across the antimeridian |
zoom |
zoom.min to zoom.max |
Integer; 0–22 by default |
The response is always image/png, served inline. There is no
Content-Disposition header, so the URL works directly in an <img> tag, as a
CSS background-image, and from any API client.
Query parameters
| Parameter | Type | Default | Bounds |
|---|---|---|---|
imagery |
string | defaults.imagery |
Must be a configured key |
marker-color |
hex colour | #dc3545 |
3, 4, 6 or 8 hex digits |
marker-size |
integer | 48 |
limits.marker_size (16–128) |
width |
integer | 800 |
limits.width (100–2000) |
height |
integer | 500 |
limits.height (100–2000) |
poi-name |
string | none | Up to label.max_length (120) characters |
poi-text-color |
hex colour | #212529 |
3, 4, 6 or 8 hex digits |
poi-bg-color |
hex colour | #ffffffd1 |
3, 4, 6 or 8 hex digits |
poi-font |
string | fonts.default |
Must be a discovered font key |
poi-font-size |
integer | 15 |
limits.poi_font_size (8–72) |
poi-font-weight |
integer | 400 |
One of label.weights (400, 500, 600, 700) |
poi-padding |
integer | 6 |
limits.poi_padding (0–32) |
poi-border-radius |
integer | 4 |
limits.poi_border_radius (0–32) |
poi-position |
string | right |
right, left, top or bottom |
Parameter names are hyphenated and are part of the public API. Internally they
map onto the camelCase properties of StaticMapOptions.
The poi-* styling parameters are only meaningful alongside poi-name. They
are accepted and ignored without one, and are deliberately left out of the
image's cache identity in that case so that requests producing identical images
share a cache entry.
Errors
Failures return JSON rather than an HTML error page, regardless of the Accept
header:
{
"message": "Invalid imagery source.",
"error": "invalid_imagery_source"
}
| Status | error |
Meaning |
|---|---|---|
| 404 | — | The path is not a valid coordinate triple |
| 422 | validation_failed |
A parameter is missing, malformed or out of bounds |
| 422 | invalid_imagery_source |
The imagery key is not configured |
| 422 | tile_limit_exceeded |
The request needs more tiles than limits.max_tiles |
| 502 | imagery_source_unavailable |
The tile provider could not be used |
| 500 | rendering_failed |
Rendering failed; details are logged, not returned |
POI text styling
Every visual property of the label is controllable per request. Nothing else about the image is affected: the marker, the imagery and the map geometry are untouched by these parameters.
/maps/static/21.543333/39.172778/15.png
?poi-name=Jeddah
&poi-text-color=%23ffffff
&poi-bg-color=%23111827ee
&poi-font=sans
&poi-font-size=22
&poi-font-weight=700
&poi-padding=9
&poi-border-radius=12
| Parameter | Controls |
|---|---|
poi-text-color |
Text colour |
poi-bg-color |
Background panel colour |
poi-font |
Font family, by configured key |
poi-font-size |
Text size in pixels |
poi-font-weight |
Font weight |
poi-padding |
Space between the text and the panel edge |
poi-border-radius |
Corner rounding of the panel |
Colours and transparency
Both colour parameters accept 3, 4, 6 or 8 hexadecimal digits. The 4 and 8 digit forms carry an alpha channel, which is how the panel is made translucent without adding a separate opacity parameter:
&poi-bg-color=%23ffffffd1 # white at ~82% opacity (the default)
&poi-bg-color=%23111827ee # dark slate, nearly opaque
&poi-bg-color=%2300000000 # fully transparent: text only, no panel
&poi-text-color=%23fff # shorthand, expanded to #ffffff
Remember to percent-encode # as %23 in a query string.
Note that the marker colour is deliberately not widened to accept alpha.
marker-color still takes 3 or 6 digits only, exactly as before.
Fonts
poi-font accepts a configured key and nothing else. There is no code path
that accepts a filename, a path or a URL, which is what keeps the label from
being used to read files off the server or to fetch a remote resource. Register
your own family to make it available:
'label' => [ 'default_font' => 'sans', 'fonts' => [ // Bundled with the package. A bare filename resolves inside the // package's own resources/fonts directory. 'sans' => [ 'label' => 'DejaVu Sans', 'weights' => [ 400 => 'DejaVuSans.ttf', 700 => 'DejaVuSans-Bold.ttf', ], ], // Your own font. An absolute path is used as given. 'brand' => [ 'label' => 'Brand Sans', 'weights' => [ 400 => '/var/www/fonts/BrandSans-Regular.ttf', 600 => '/var/www/fonts/BrandSans-SemiBold.ttf', 700 => '/var/www/fonts/BrandSans-Bold.ttf', ], ], ], ],
Requests may then use ?poi-font=brand. The playground picks the family list up
automatically, using label for the display name and deriving one from the key
when it is absent.
Weights
poi-font-weight accepts the values in label.weights, which defaults to
[400, 500, 600, 700]. A weight with a registered font file uses that file. A
weight without one resolves to the nearest available face, and the shortfall
is made up with a stroke in the fill colour.
With only regular and bold registered, that means:
| Requested | Face used | Emboldened |
|---|---|---|
| 400 | DejaVuSans.ttf |
no |
| 500 | DejaVuSans.ttf |
yes, slightly |
| 600 | DejaVuSans-Bold.ttf |
no |
| 700 | DejaVuSans-Bold.ttf |
no |
Synthetic emboldening is a compromise, not a substitute for a real face — but it keeps intermediate weights visually distinct instead of silently collapsing several of them into one appearance. Register the faces you care about to avoid it entirely.
Styling defaults
Every parameter's default is configurable, and every bound is enforced:
'label' => [ 'text_color' => '#212529', 'background_color' => '#ffffffd1', 'font_size' => 15, 'font_weight' => 400, 'padding' => 6, 'border_radius' => 4, ], 'limits' => [ 'poi_font_size' => ['min' => 8, 'max' => 72], 'poi_padding' => ['min' => 0, 'max' => 32], 'poi_border_radius' => ['min' => 0, 'max' => 32], ],
Layout — the gap between marker and label, the margin kept clear of the image edges, the maximum text length and the share of the image width a label may occupy — stays in configuration rather than becoming a query parameter:
'label' => [ 'gap' => 6, 'margin' => 6, 'max_length' => 120, 'max_width_ratio' => 0.6, ],
A corner radius larger than half the panel's shorter side is capped, since overlapping corners draw as a distorted shape rather than a pill.
Programmatically
$png = app(StaticMapGenerator::class)->generate(new StaticMapOptions( latitude: 21.543333, longitude: 39.172778, zoom: 15, imagery: 'osm', markerColor: '#dc3545', markerSize: 48, width: 800, height: 500, poiName: 'جدة', poiTextColor: '#ffffff', poiBackgroundColor: '#111827ee', poiFontSize: 22, poiFont: 'sans', poiFontWeight: 700, poiPadding: 9, poiBorderRadius: 12, ));
Or derive a variant from an existing options object:
$emphasised = $options->with(poiFontWeight: 700, poiFontSize: 22);
Transparent colours
Every colour parameter — marker-color, poi-text-color and poi-bg-color —
accepts an alpha channel:
#RRGGBB fully opaque
#RRGGBBAA with transparency
#RGB shorthand, expanded as CSS does
#RGBA shorthand with transparency
#ff0000ff opaque red
#ff000080 red at 50%
#ff000000 fully transparent red, so nothing is drawn
Six digits mean fully opaque, so every URL written before transparency
existed renders exactly the image it always did. An opaque colour is also
rendered back into a generated URL as six digits rather than gaining a
redundant ff, which keeps stored URLs stable.
# A translucent marker over the map
?marker-color=%23dc3545cc
# White label text on a dark, nearly opaque panel
?poi-text-color=%23ffffff&poi-bg-color=%23111827ee
# Text with no panel behind it at all
?poi-bg-color=%2300000000
Remember to percent-encode # as %23.
Anything that is not plain hexadecimal is rejected: rgba(), hsl(),
transparent, currentColor, url(#x), CSS declarations and markup all fail
validation. Alpha is part of the cached image's identity, so two requests
differing only in transparency are two different images.
Label position
poi-position places the label on one of four sides of the marker:
?poi-name=Jeddah&poi-position=right (default)
?poi-name=Jeddah&poi-position=left
?poi-name=Jeddah&poi-position=top
?poi-name=Jeddah&poi-position=bottom
TOP RIGHT
┌──────────┐
│ Jeddah │ ┌──────────┐
└──────────┘ ▄▀▀▄ │ Jeddah │
▄▀▀▄ █ ●█ └──────────┘
█ ●█ ▀▄▀
▀▄▀ ▼
▼ ═════════╪═════════
════════╪════════
The marker never moves. Its tip stays anchored to the requested coordinate whichever side the label is drawn on, and the label cannot affect it. A label that will not fit on the requested side is moved to the opposite side rather than being clipped, and only clamped inside the frame if neither side fits.
Only the four values are accepted; center, north, top-left, coordinates
and CSS keywords are all rejected. The set can be narrowed but not widened:
'label' => [ 'position' => 'right', 'positions' => ['right', 'left'], ],
A label above or below the marker may use the full image width, since it shares
that axis with nothing; one beside the marker is held to
label.max_width_ratio so it does not crowd out the map.
Fonts
Bundled, so nothing needs installing
Fonts live inside the package, at resources/fonts. The default configuration
never depends on a font being installed on the host system.
Coverage differs between families, and it matters — a font can only draw the scripts it has glyphs for:
Counted over Latin A-Z a-z (52), the Arabic range U+0621-U+064B (43) and
Presentation Forms-B U+FE70-U+FEFF (144). Reproduce any row with the snippet
below.
| Bundled family | Faces | Latin | Arabic | Forms-B | Notes |
|---|---|---|---|---|---|
| DejaVu Sans | 2 | 52/52 | 38/43 | 141/144 | The default; covers both scripts |
| IBM Plex Sans Arabic | 1 | 52/52 | 43/43 | 140/144 | The most complete coverage of both |
| Cairo | 1 | 52/52 | 38/43 | 89/144 | Partial Forms-B; see below |
| Noto Sans Arabic | 1 | 0/52 | 43/43 | 140/144 | No Latin at all: mixed labels lose it |
| Inter | 1 | 52/52 | 0/43 | 1/144 | Latin only, shipped as OTF |
The bundled Cairo.ttf is a subset of the Cairo Black face — its internal
family name is "Cairo Black" and it carries 699 glyphs. Discovery derives keys
and weights from the file name, so it is offered as cairo at weight 400 while
actually drawing in Black. Rename the file (Cairo-Black.ttf) if you want the
weight it really is.
Cairo is the interesting case and the reason glyph-aware shaping exists. It
covers the Arabic block well but only 89 of the 144 presentation forms —
U+FE93, the isolated teh marbuta, among the missing. Substituting presentation
forms unconditionally therefore drops the last letter of جدة. With glyph-aware
shaping it falls back to the final form, which Cairo does have, and the word
renders in full.
Presentation Forms-B is the column that decides whether Arabic shapes correctly on a build without Raqm, because that is the block the bundled shaper emits.
Verify a font yourself:
use Thtc\StaticMaps\Fonts\{FontGlyphs, FontRepository}; $font = app(FontRepository::class)->find('ibm-plex-sans-arabic-regular'); $glyphs = new FontGlyphs($font->path); $glyphs->supports(0xFE93); // the isolated teh marbuta $glyphs->count(); // how many codepoints the font maps
If you add your own, check that it covers the scripts you need. The package logs a warning naming the exact missing codepoints when a label asks for glyphs the chosen font does not have, which is otherwise invisible in the output.
Automatic discovery
Fonts are not registered in configuration. The package scans its font directory and derives everything from the filenames:
NotoSansArabic-Regular.ttf -> key: noto-sans-arabic-regular
label: "Noto Sans Arabic Regular"
family: noto-sans-arabic
weight: 400
Both .ttf and .otf are discovered. Adding a font is copying a file in.
Derivation is deterministic, because those keys appear in URLs and cache
identities. Filenames are split on separators, PascalCase boundaries and
letter/digit boundaries; anything that is not a letter or digit is dropped, so
a filename can never carry path characters or markup into a key or a label.
Compound weight names are understood, so Font-ExtraBold is weight 800 in
family font, not weight 700 in family font-extra.
If two files derive the same key — most obviously the same font present in two
scanned directories — the first discovered keeps the plain key and later ones
are suffixed -2, -3, and so on.
Adding directories
'fonts' => [ // Scanned after the package's own directory, which is always scanned. 'directories' => [ resource_path('fonts'), '/var/www/shared-fonts', ], 'extensions' => ['ttf', 'otf'], // A discovered key, or null to use the first font found. 'default' => 'deja-vu-sans', ],
Requests select a font by key with poi-font. Filenames, absolute paths,
relative paths, traversal sequences, file:// and https:// URLs, data:
URLs, CSS font stacks and @font-face rules are all rejected — the only values
accepted are keys the scan produced.
Weights
poi-font-weight accepts the values in label.weights ([400, 500, 600, 700]
by default). A weight is served by a real face from the same family when one
was discovered, and otherwise by the nearest face with a little synthetic
emboldening for the difference. Bundling Family-Bold.ttf alongside
Family-Regular.ttf is all it takes to make bold labels genuinely bold.
Glyph-aware shaping
Shaping substitutes base Arabic letters for Unicode presentation forms, which
only works if the font has those glyphs — and coverage of that block varies
much more than coverage of the Arabic block itself. Cairo, for instance, renders
Arabic beautifully in a browser yet omits U+FE93, the isolated teh marbuta.
Substituting it unconditionally drops the last letter of جدة with that font,
silently, with nothing in the output or the log to say why.
So the shaper asks the font first. A presentation form is only used when the font can draw it, and otherwise it falls back through the remaining forms and finally to the base letter — losing the join, never the character. The same applies to the mandatory lam-alef ligature: a font without the ligature glyph keeps the two letters instead.
This is why the package reads each font's character map. It is also why the renderer logs the exact missing codepoints when a label needs glyphs the font lacks.
Examples
<img src="/maps/static/21.543333/39.172778/15.png" width="800" height="500" alt="Jeddah">
# Everything at once
/maps/static/21.543333/39.172778/15.png?imagery=osm&marker-color=%23ff0000&marker-size=48&width=800&height=500&poi-name=Jeddah
# Larger image, bigger marker
/maps/static/21.543333/39.172778/15.png?width=1200&height=700&marker-size=64
# Arabic label
/maps/static/21.4225/39.8262/14.png?poi-name=%D9%85%D9%83%D8%A9%20%D8%A7%D9%84%D9%85%D9%83%D8%B1%D9%85%D8%A9
# Styled label: white bold text on a dark rounded panel
/maps/static/21.543333/39.172778/15.png?poi-name=Jeddah&poi-text-color=%23ffffff&poi-bg-color=%23111827ee&poi-font-size=22&poi-font-weight=700&poi-border-radius=12&poi-padding=9
# Label with no background panel
/maps/static/21.543333/39.172778/15.png?poi-name=Jeddah&poi-bg-color=%2300000000&poi-text-color=%230d6efd&poi-font-weight=700
# Satellite imagery, no label
/maps/static/24.7136/46.6753/13.png?imagery=satellite-thtc
Note that # must be percent-encoded as %23 in a query string.
<div style="background-image: url('{{ route('static-maps.image', [ 'lat' => $place->latitude, 'lng' => $place->longitude, 'zoom' => 15, ]) }}?width=600&height=300')"></div>
Programmatic use
The generator takes a plain options object rather than an HTTP request, so it works from a queued job, a console command or a test without fabricating a request.
use Thtc\StaticMaps\Data\StaticMapOptions; use Thtc\StaticMaps\StaticMapGenerator; $png = app(StaticMapGenerator::class)->generate(new StaticMapOptions( latitude: 21.543333, longitude: 39.172778, zoom: 15, imagery: 'osm', markerColor: '#ff0000', markerSize: 48, width: 800, height: 500, poiName: 'Jeddah', )); Storage::put('maps/jeddah.png', $png);
StaticMapOptions is immutable; derive variants with with():
$retina = $options->with(width: 1600, height: 1000);
Let the configured defaults fill in the rest:
$generator = app(StaticMapGenerator::class); $options = $generator->options([ 'latitude' => 21.543333, 'longitude' => 39.172778, 'poiName' => 'Jeddah', ]); $png = $generator->generate($options);
Note that options() does not validate. Validate untrusted input yourself, or
go through the HTTP endpoint, which does.
Other useful calls:
$generator->render($options); // bypass the image cache $generator->etag($options); // ETag without rendering anything $generator->isCached($options); // is a rendered image already cached $generator->wasCacheHit(); // did the last generate() hit the cache
There is no facade. The services are resolvable from the container, and adding a facade purely to have one would not earn its keep.
Playground
/maps/static/playground
A single centred card with every parameter, a live preview that preserves the image's aspect ratio, and the generated URL with a copy button. Generation happens when you press Generate, not on every keystroke.
Alongside the map controls, a POI text styling group exposes exactly the label options the endpoint accepts:
| Control | Parameter |
|---|---|
| POI name | poi-name |
| Text colour | poi-text-color |
| Background colour | poi-bg-color |
| Font | poi-font |
| Font weight | poi-font-weight |
| Position | poi-position |
| Font size | poi-font-size |
| Padding | poi-padding |
| Border radius | poi-border-radius |
The styling group is disabled until a POI name is entered, because none of it affects an image with no label.
Colour pickers
Each of the three colours — marker, label text, label background — is one compact control showing a swatch and its value. Opening it reveals a native colour input for the red, green and blue choice and a separate alpha slider, because a native colour input has no concept of transparency. A checkerboard behind the swatch and the alpha track makes transparency obvious rather than looking like a slightly different shade.
The value can also be typed directly in the panel's hex field, in any of the accepted forms. Whatever is chosen is written into a hidden input under the same parameter name, so URL generation is unchanged.
Font picker
A native <select> cannot render each option in its own typeface, so the font
control is a Bootstrap dropdown. Each option shows the font's name beside a
preview of the current POI name rendered in that font — the browser loads
the very file the server will render with, so what you see is what you get. The
preview updates as the POI name changes, and falls back to a mixed Latin and
Arabic sample when the name is empty.
A search field at the top of the open list filters by display name and key,
case-insensitively, and reports No fonts found when nothing matches. Arrow
keys walk the filtered list and Enter selects. The closed control stays exactly
as tall as the inputs beside it.
Current coordinates
A small button beside the coordinate fields fills latitude and longitude from
navigator.geolocation. It is one-shot: nothing is requested until the button
is pressed, nothing is watched afterwards, the zoom is left alone, and the
coordinates go no further than the two inputs until Generate is pressed.
Permission denial, unavailability and timeouts each produce a short message
rather than a silent failure.
The loading state lives in the Generate button: a Bootstrap spinner sits inside it, the label changes to Generating…, the button is disabled and further submissions are ignored until the request settles, and the preview dims rather than being covered. The button reserves enough width for both labels so it does not resize as the state changes.
The form's bounds are rendered from the same configuration the endpoint validates against, so it cannot offer a combination the API would reject. The URL is assembled from a template generated by the router, so the route path is defined once in PHP rather than being reconstructed in JavaScript.
Protect it in production if you would rather not expose it:
'route' => [ 'playground' => [ 'middleware' => ['web', 'auth'], ], ],
Or turn it off entirely, which also removes the asset routes:
'route' => [ 'playground' => ['enabled' => false], ],
The image endpoint's middleware is configured separately, so the playground can be locked down while the images stay public.
Caching
Two independent layers, both using Laravel's cache abstraction.
Tiles are cached individually under a key naming the imagery source and the coordinate:
static-maps:tile:v1:{imagery}:{z}:{x}:{y}
Including the imagery key means two sources can never be served each other's tiles. Tiles are the highly reusable layer: neighbouring requests at the same zoom overwhelmingly need tiles someone has already paid for. Failed tiles are never cached, so a provider hiccup is not remembered for a day.
Rendered images are cached under a digest of every parameter that affects the output:
static-maps:image:v1:{sha256}
The digest is taken over an explicitly ordered canonical array, so
?a=1&b=2 and ?b=2&a=1 share one entry. Coordinates are rounded to
cache.coordinate_precision decimal places (6 by default, roughly 0.1 m at the
equator) so requests that differ only in trailing zeros do not each get their
own entry.
Every output-affecting parameter is part of that digest, including the ones that are easy to overlook:
- marker colour and its alpha
- label text colour and its alpha
- label background colour and its alpha
- label position
- font key and weight
Changing any of them produces a different image and therefore a different cache entry. Label styling and position are excluded from the identity when there is no label, because they cannot affect the output — otherwise every styling combination would spend an entry on byte-identical images.
The imagery credential is deliberately not part of the identity, so rotating a key does not invalidate the cache and no credential reaches a cache backend.
Consequently:
- First request: fetch tiles → render → cache → respond (
X-Static-Maps-Cache: MISS) - Subsequent requests: respond from cache with no fetching and no rendering (
X-Static-Maps-Cache: HIT)
Use a dedicated store to keep map images out of your application cache:
// config/cache.php 'stores' => [ 'maps' => ['driver' => 'redis', 'connection' => 'maps'], ],
STATIC_MAPS_CACHE_STORE=maps
Bump CacheKeyFactory::VERSION if you ever need to invalidate everything at
once without flushing the store by hand.
HTTP caching and ETags
Content-Type: image/png Cache-Control: max-age=86400, public, s-maxage=86400 ETag: "v1-8e72c3284879..." X-Static-Maps-Cache: HIT
The ETag is derived from the request parameters, not from the rendered bytes.
That has a useful consequence: a conditional request can be answered with
304 Not Modified before a single tile is fetched or a single pixel drawn.
GET /maps/static/21.543333/39.172778/15.png If-None-Match: "v1-8e72c3284879..." 304 Not Modified
Weak validators (W/"..."), comma-separated candidate lists and * are all
handled. Set response.etag to false to disable.
Redis
Nothing special is required — point the cache store at Redis:
CACHE_STORE=redis # or, to isolate map data STATIC_MAPS_CACHE_STORE=maps
Tiles are binary PNG blobs of a few kilobytes each. A busy map at zoom 15
covering a city is on the order of tens of megabytes of tile cache. Setting
maxmemory-policy allkeys-lru on a dedicated Redis database is a reasonable
way to bound it.
Nginx
Nginx caching is entirely optional. Laravel's own cache does the work without it, and everything below is a bonus layer that keeps repeat requests from reaching PHP at all.
proxy_cache_path /var/cache/nginx/static_maps levels=1:2 keys_zone=static_maps:10m max_size=1g inactive=7d; server { location /maps/static/ { proxy_pass http://127.0.0.1:9000; proxy_cache static_maps; proxy_cache_valid 200 1d; proxy_cache_valid 404 422 1m; # The query string is part of the image identity. proxy_cache_key "$scheme$request_method$host$request_uri"; # Collapse a stampede on a cold cache into one upstream request. proxy_cache_lock on; proxy_cache_use_stale error timeout updating http_500 http_502 http_503; add_header X-Nginx-Cache $upstream_cache_status; } }
The endpoint sends s-maxage, which shared caches including Nginx honour, so
the TTL can be driven from configuration instead of being hard-coded here.
Exclude the playground if you cache aggressively — it is an HTML page, not an
image.
Security
Security was treated as a functional requirement rather than an afterthought. Each item below has tests covering it.
SSRF
Requests name imagery by key only. URL templates come exclusively from
configuration, and there is deliberately no code path that accepts a
caller-supplied tile URL. Attempts to pass a URL, a cloud metadata endpoint, a
file:// scheme or a traversal string are rejected at validation with a 422,
before any outbound request is made.
Templates are additionally checked to be http/https and to contain all
three placeholders, so a misconfiguration fails loudly.
SVG and drawing-primitive injection
marker-color is validated against /^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/D
and normalised to #rrggbb before it reaches the drawing layer, where it is
passed as an ImagickPixel rather than interpolated into markup. Payloads such
as "></path><script>, gradient:red-blue, url(#x) and rgb(255,0,0) are
all rejected.
The D modifier matters: without it PHP's $ also matches before a trailing
newline. The trim character list deliberately excludes NUL, so a colour
carrying a control byte is refused rather than silently cleaned up.
Label injection
poi-name is checked for valid UTF-8, has whitespace collapsed, and has all
control characters stripped — including the bidirectional overrides that could
otherwise be used to scramble the rendered text. It is drawn as text, never
interpreted as markup. Long values are truncated at a grapheme boundary.
Font selection
poi-font accepts only keys produced by the directory scan, matched with a
strict comparison. Filenames, absolute and relative paths, traversal sequences,
file:// and https:// URLs, data: URLs, CSS font stacks and @font-face
rules are all rejected. Font files themselves are located only by scanning
configured directories: subdirectories are not descended into and symlinks are
not followed, and a configured path containing .. or a NUL byte is refused
even though configuration is trusted.
The playground serves font files so it can preview them, and only the exact files the scan discovered: the requested name is matched against that list rather than joined onto a directory, and a name whose basename differs from itself is refused outright.
Label styling injection
None of the styling parameters accept free-form input:
poi-text-colorandpoi-bg-colorare hexadecimal digits only, validated with the same strictness as the marker colour.rgba(),hsl(),url(#x),gradient:red-blue,label:@/etc/passwd, CSS declarations and markup are all rejected.poi-fontis matched against the configured keys with a strict comparison. Filenames, absolute paths, relative paths, traversal sequences,file://andhttps://URLs,data:URLs, CSS font stacks and@font-facerules are all rejected. The resolver is the only thing that turns a key into a path, and it refuses a traversal sequence even from configuration.poi-font-weightis matched against a controlled list.poi-font-size,poi-paddingandpoi-border-radiusare integers bounded by configuration.
Resource exhaustion
width, height, marker-size and zoom are all bounded by configuration,
and limits.max_tiles caps how many tiles one image may require. The tile
budget is counted against distinct tiles, which is what actually costs an
upstream request.
Cache poisoning
Cache keys are a fixed-length digest of a canonically ordered parameter set. They contain no caller-supplied text, so there is nothing to smuggle in, and they are safe for stores that map keys onto filesystem paths.
Path traversal
No request value is ever used to build a filesystem path. Label fonts are resolved from configuration and the package directory only. The asset route serves from a fixed two-entry whitelist rather than concatenating the requested name onto a directory.
Credential leakage
Tile URLs are passed through LogSanitizer before being logged, which redacts
key, apikey, api_key, token, access_token, signature and secret
by default, and always strips user:pass@ credentials from the authority. The
redaction list is configurable. Internal exception messages are logged, never
returned to the caller.
Response integrity
A tile response is only accepted as image data if it declares an image/*
content type or begins with the magic bytes of a PNG, JPEG, GIF, BMP or WebP.
This matters more than it sounds: providers commonly answer a rejected API key
with HTTP 200 and a JSON error body. Accepting that produces a blank grey
map with a success status, which is a genuinely expensive thing to debug. The
package returns a 502 instead.
Performance
- Tiles are fetched concurrently through a bounded pool
(
http.concurrency, 8 by default) rather than one after another. A typical 800×500 image needs 12 tiles; serially that would multiply the slowest round trip twelvefold. - Each distinct tile is fetched once and decoded once, then stamped at every position it occupies. At low zoom, or across the antimeridian, the same tile legitimately appears several times.
- Tiles are composited at their natural size and the canvas is cropped once at the end. Nothing is ever resized, so imagery is never resampled.
- A cache hit costs no outbound requests and no image work at all.
- A matching
If-None-Matchcosts neither, returning 304 immediately. - Native Imagick resources are released explicitly after each render rather than being left to the garbage collector.
How the marker is rendered
This is the part most static-map implementations get wrong, so it is worth spelling out.
The marker is a fixed vector outline authored in an 18×24 coordinate system.
Rather than handing an SVG document to the image library, the package parses the
path data itself and replays it through ImagickDraw's vector path API.
Two reasons:
- It does not depend on an SVG delegate. Hardened ImageMagick installations
routinely disable the SVG coder in
policy.xml, and some builds reportSVGas a supported format while having no delegate that can actually read it. A marker that silently vanishes in production is not an acceptable failure mode. - It is drawn at the requested size. The outline is scaled as geometry, not
as pixels, so
marker-size=128is as crisp asmarker-size=16. Rendering small and scaling up — the obvious shortcut — produces visible stair-stepping.
The outline is filled with the even-odd rule, which is what makes the ring in the pin's head read as a hole rather than being painted over.
Anchoring
The requested coordinate is at the exact centre of the image, and the marker's anchor is the point of the pin, not the centre of its bounding box:
┌────────┐
│ ▄▄▄▄ │
│ █ ▄▄ █ │ marker, 18×24 units
│ █ ▀▀ █ │
│ ▀▄▄▀ │
│ ▀▀ │
└────▼───┘
│
═══════╪═══════ ← image centre row
│
the requested lat/lng
Centring the bounding box instead would float the marker roughly half its height
above the place it describes. marker-size is the marker's height; the width
is derived from the 3:4 aspect ratio, so raising the size grows the pin
proportionally rather than stretching it.
A note on precision: cropping happens on integer pixel boundaries, because cropping at a fractional offset would mean resampling — and blurring — the imagery. The map is therefore centred to within half a pixel, which is the best a non-resampling crop can do. The marker itself is drawn on the exact centre pixel, so the pin, the image centre and the requested coordinate agree.
POI labels
poi-name draws a label beside the marker. It is deliberately best-effort:
any failure — an unresolvable font, an unmeasurable string, a broken glyph — is
logged and swallowed, and the map is returned without the caption. Losing a
label is a far better outcome than losing the map.
Placement puts the label to the right of the pin, vertically centred on its
head. If it would overflow the right edge it flips to the left; if it fits
neither side it is clamped inside the frame and truncated with an ellipsis at a
grapheme boundary. Labels are capped at 60% of the image width by default
(label.max_width_ratio).
The label is drawn as a rounded background panel with the text inside it. Both are fully styleable per request — see POI text styling — and every default lives in configuration.
Fonts resolve in order: a real face from the requested family at the requested weight, then the nearest face in that family, then the selected file itself, then any other discovered font. If none resolve the label is skipped with a warning naming every path that was tried. Font paths come only from the directory scan, never from a request.
'label' => [ // A discovered key, or null to use the first font the scan finds. 'font' => null, ], 'fonts' => [ // The package's own directory is always scanned; these are additional. 'directories' => [], ],
Arabic and other complex scripts
Arabic works out of the box, including alongside Latin text and digits. Getting there needs a little explanation, because this is the sharpest edge in the whole package.
Arabic requires two transformations that Latin does not:
- Joining. Each letter has up to four shapes depending on its neighbours, and the base codepoints carry no shape at all.
- Ordering. Arabic runs right to left, and mixed text needs the bidirectional algorithm to work out the visual order.
ImageMagick performs both — correctly, via HarfBuzz and FriBidi — only when it was compiled against Raqm. Many builds are not, including Homebrew's. On those builds, handing raw Arabic to the text renderer produces a row of disconnected isolated letters running the wrong way.
So the package detects what the image library can do and adapts:
- Raqm available → the text is passed through untouched. HarfBuzz does a better job than any pure-PHP approximation could.
- Raqm unavailable → the package shapes the text itself: it applies
contextual presentation forms (
U+FE70–U+FEFFandU+FB50–U+FBFF), forms the mandatory lam-alef ligatures, and reorders the result into visual order using the Unicode L2 rule over resolved embedding levels.
Detection reads ImageMagick's linked delegate list. Note that
Imagick::queryFormats() is not a usable signal here: it reports PANGO
and SVG as supported on builds that have no delegate for either and fail at
read time.
Things that come out right:
| Input | Rendered |
|---|---|
جدة |
letters joined, right to left |
جدة Jeddah |
Arabic on the right, Latin on the left (RTL base direction) |
Jeddah جدة |
Latin on the left, Arabic on the right (LTR base direction) |
لا |
a single lam-alef ligature, not two letters |
شارع 15 |
Arabic right to left, the number left to right and ascending |
مَكَّة |
diacritics on the letters they belong to |
Font coverage matters
The in-package shaper emits Unicode presentation forms, so the font must map the Arabic Presentation Forms-B block. Many otherwise excellent Arabic fonts omit it and rely on the renderer performing OpenType shaping — which is exactly what is unavailable on a build without Raqm. Noto Sans Arabic and Noto Naskh Arabic both ship Forms-B but cover almost no Latin, so mixed labels would show missing-glyph boxes for the Latin half.
The package therefore bundles DejaVu Sans in regular and bold, both of which cover Latin, the Arabic block and Forms-B. If you register your own font and Arabic labels render as boxes, that is almost certainly the cause.
DejaVu Sans covers 38 of the 43 codepoints in the base Arabic range. In practice this does not matter, because the shaper maps base letters to presentation forms before rendering, and Forms-B coverage is complete.
Scope
The bundled reorderer is a pragmatic single-line implementation, not a complete Unicode Bidirectional Algorithm. It resolves embedding levels and applies rule L2, which covers place names and captions well. Explicit directional overrides, isolates and paired-bracket resolution are out of scope. On a Raqm-enabled build none of this code runs.
Input must be in logical order, which is how text is normally stored. Shaping is applied exactly once per render.
Image guarantees
- Always
image/png, never JPEG. - Exactly the requested
width×height. - Served inline; no
Content-Disposition, so it never prompts a download. - No resizing, resampling or stretching of the imagery, and the requested zoom is always honoured.
- Tiles that could not be fetched are drawn as a configurable placeholder fill
rather than failing the whole image — unless every tile failed, or
http.fail_on_tile_erroris enabled. - Alpha is preserved (
png32), and the virtual canvas left behind by cropping is reset so no viewer applies a phantom offset.
Testing
The suite is 634 tests and about 2,100 assertions.
# From the host application php artisan test --testsuite=StaticMaps # From the package directory composer install composer test
No test ever contacts a real tile provider. Tiles are generated locally with
Imagick and served through Http::fake(), which means the suite is
deterministic, offline and fast.
Coverage worth knowing about:
- Projection maths checked against reference tile indices at zoom 0, 1, 12, 15,
18, 19 and 22, cross-validated against the textbook
ln(tan + sec)form, plus round-tripping, pole clamping and antimeridian wrapping. - Tile geometry: coverage, crop containment, centring to within half a pixel, world-wrap de-duplication, the tile budget, and that no out-of-range index is ever emitted.
- Pixel-level rendering: that the marker's tip lands on the exact centre pixel across six size and dimension combinations, that the requested colour is applied, that the pin's hole is not filled, and that painted area scales quadratically with marker size — which is what distinguishes vector rendering from an upscaled bitmap.
- Every documented failure mode, including the 200-with-JSON case.
- Caching: that a second request fetches nothing, that parameter order does not create duplicate entries, that each parameter caches separately, that sources do not collide, and that a conditional request renders nothing at all.
- Arabic joining, lam-alef ligatures, bidirectional ordering, digit runs and combining marks, asserted as explicit codepoint sequences.
- Label styling: that each parameter is accepted at its bounds and rejected
beyond them; that colours reject
rgba(),url(), CSS and markup; thatpoi-fontrejects filenames, paths, traversal sequences and URLs; and that each option measurably changes the pixels — text and panel colour, alpha blending against the map, size, padding, corner rounding and weight. - Font resolution by key and weight, nearest-weight matching, synthetic emboldening, traversal refusal, and that resolution still succeeds however badly the font configuration is broken.
- That styling changes the cached image's identity, that equivalent styling does not, and that styling is excluded from the identity when there is no label.
- Transparency: 6 and 8 digit forms, fully opaque, fully transparent and
everything between; that 6 digits and
ffproduce byte-identical images; that a translucent marker blends with the map rather than covering it; and that a fully transparent marker draws nothing at all. - Position: all four values accepted and everything else rejected; that each panel lands on the correct side of the marker; that the panel is the same size whichever side it is on; and that the marker's tip stays on the coordinate for every position, including in frames too small for the requested side.
- Font discovery: TTF and OTF, key and display-name derivation, compound weight names, collision suffixing, determinism across scans, additional directories, non-font files ignored, subdirectories not descended into, and that the bundled fonts work with no configuration at all.
- Glyph coverage: a regression test for
جدة, asserting the third letter adds ink rather than vanishing; that shaping never emits a glyph the chosen font lacks, across every Arabic-capable bundled font; and that font-aware shaping never draws less than font-blind shaping would. - The imagery credential: that it is appended to tile requests, not duplicated when already inline, honours a custom parameter name, and never appears in a generated URL, the playground, a response header, a cache key or the log.
- Architecture tests asserting the package never references the host application — see Development.
Assertions are made on image properties and pixels, never on binary equality, which would be brittle across ImageMagick builds.
Development
The package is developed inside a host application through a Composer path repository, and is designed to be extracted into its own repository unchanged.
{
"repositories": [
{
"type": "path",
"url": "packages/laravel-static-maps",
"options": { "symlink": true }
}
],
"require": {
"thtc/laravel-static-maps": "dev-main"
}
}
That boundary is enforced by architecture tests rather than by good intentions:
the package must not reference App\, must not use the host's directory helpers
outside the two classes whose job is to name publish destinations, must declare
strict types everywhere, must keep its data objects readonly, and must not
contain debugging leftovers. If the boundary is ever crossed, the build fails.
vendor/bin/pint # format composer test # test
Building the playground assets
The package has its own package.json and vite.config.js, entirely
independent of the host application's frontend build.
cd packages/laravel-static-maps
npm install
npm run build
That compiles resources/css/playground.scss and resources/js/playground.js
into dist/, alongside a manifest.json holding a content digest used for
cache busting and ETags. The built files are committed, so consumers never
need to run npm.
Bootstrap 5 is compiled in from a deliberate subset — reboot, grid, forms,
buttons, card, alerts, spinners and the utilities API — rather than in full. The
result is about 135 KB of CSS, 20 KB gzipped. The playground is served as a
standalone document, so Bootstrap cannot collide with host styles; the
package's own rules are additionally scoped under .static-maps-playground so
that publishing the CSS cannot leak layout onto application pages.
The JavaScript has no dependencies. Copying to the clipboard uses the Clipboard
API with a document.execCommand fallback for non-secure contexts.
Troubleshooting
Class "Imagick" not found — the extension is not installed.
pecl install imagick, or apt install php8.3-imagick, then restart PHP-FPM.
Every request returns 502 imagery_source_unavailable — the tile provider
is unreachable or rejecting requests. Check the log: the sanitised tile URL and
a specific reason are recorded. A reason of "Response was not an image"
usually means an invalid or expired API key, since providers often answer that
with HTTP 200 and a JSON body.
The map renders but tiles are blank grey — some tiles failed while others
succeeded. The log will say which. Set http.fail_on_tile_error to true to
make partial failures fatal instead.
Arabic labels render as boxes — the font registered for the requested family and weight does not cover the Arabic Presentation Forms-B block. Use the bundled family, or register one that does; see Font coverage matters.
Invalid POI font. — poi-font must be a key the font scan produced, not a
filename or path. List them with
app(Thtc\StaticMaps\Fonts\FontRepository::class)->keys().
Two font weights look identical — they resolved to the same face. Drop a
font file for that weight into the font directory, named so its weight is
derivable, for example Family-SemiBold.ttf.
Part of an Arabic label is missing — the font lacks a glyph the label needs.
The log names the exact codepoints and the font, for example
missing: ["U+0629","U+062F","U+062C"] with glyphs_in_font: 215, which is the
signature of a Latin-only subset that happens to be named after an Arabic
family. Use a font with real Arabic coverage; see Fonts.
A whole label is missing with an Arabic font — most likely the same thing.
A 30 KB file named Cairo-Regular.ttf is a Latin subset, not the full family.
A published config file is missing the label styling keys — it predates this
feature. Nothing needs to change: configuration is merged recursively, so the
new defaults arrive automatically. Re-publish with --force only if you want
the documented comments.
Arabic renders unjoined or reversed — you are using a custom text pipeline,
or your ImageMagick reports Raqm support it does not have. Check
Imagick::getConfigureOptions('DELEGATES').
The label does not appear at all — no font could be resolved. A warning is
logged with every path that was tried. Check that the package's
resources/fonts directory still contains its font files.
The playground is unstyled — the assets have not been built. The page says
so explicitly. Run npm install && npm run build inside the package.
tile_limit_exceeded — the image needs more tiles than limits.max_tiles.
Reduce the dimensions, lower the zoom, or raise the limit knowing it costs more
upstream requests.
Changes to the marker are not visible — images are cached for a day. Run
php artisan cache:clear, or bump CacheKeyFactory::VERSION.
No such file or directory for a bundled font — the package was installed
without its resources/ directory, which happens with an over-aggressive
.gitattributes export filter. Reinstall, or point fonts.directories at a
directory that does contain fonts.
Imagery licensing and attribution
You are responsible for how you use imagery. This package does not grant, imply or arrange any right to any tile source. Before using a provider in production, satisfy yourself about:
- the licence and terms of service for the imagery
- attribution requirements
- rate limits and acceptable-use policies
- whether caching tiles is permitted, and for how long
The default configuration includes Google and THTC endpoints because they were requested. Their presence is not a statement that you may use them: Google's tile endpoints in particular are not a public API and using them outside the Google Maps Platform terms is very likely a violation. The OpenStreetMap entry is included because it needs no key and makes the playground work immediately; its tile usage policy applies, and it is not intended for production traffic.
No attribution is invented on your behalf. Configure it and switch on rendering if a source requires it:
'attribution' => [ 'render' => true, 'sources' => [ 'osm' => '© OpenStreetMap contributors', 'my-tiles' => '© Example Ltd', ], ],
Attribution rendering is off by default so that nothing is drawn onto your images without you asking for it.
Design decisions
Why Imagick and not GD? GD cannot draw Bézier curves. Rendering the marker with GD would mean flattening the curves by hand and supersampling to fake antialiasing, and the result would still be worse. Imagick draws the outline as vectors at the target size.
Why not illuminate/image? Laravel 13 ships an image component, and it was
evaluated. It is a transformation pipeline — scale, crop, resize, blur, rotate —
with no compositing, drawing or text primitives, so it cannot build a tile
mosaic with vector overlays.
Why parse SVG paths instead of using the SVG coder? Because the coder is
frequently disabled by policy.xml in production, and some builds claim SVG
support they do not have. See
How the marker is rendered.
Why is the label background a hex colour with alpha rather than a separate
opacity parameter? Because it keeps the public surface to one parameter per
visual property, and an 8-digit hex is still strictly hexadecimal, so the
validation stays as narrow as it was. Widening the shared colour parser was
avoided: marker-color accepts exactly what it did before.
Why synthesise intermediate font weights instead of rejecting them? The weight list is configurable, so an application can offer only the weights it has faces for. The default list offers four, and collapsing 500 and 600 into identical output while charging them separate cache entries would be worse than approximating them.
Why is styling excluded from the cache key when there is no label? Because it has no effect on the output. Including it regardless would spend a cache entry per styling combination on images that are byte-identical.
Why read font character maps at all? Because "does this font support Arabic" has no single answer. A font can cover the Arabic block completely and still omit much of the Presentation Forms block that shaping produces, and the failure mode is a silently missing letter. Asking the font is the only way to choose a form it can actually draw.
Why discover fonts instead of registering them? Because a registry is a second source of truth that drifts from the directory. Scanning means adding a font is copying a file, and the renderer and the playground cannot disagree about what exists.
Why derive keys from filenames rather than font metadata? Reading the name
table would give prettier labels, but filenames are stable, visible and
diffable, and the derivation is reproducible without parsing the binary. The
cost is that DejaVuSans.ttf becomes "Deja Vu Sans"; the benefit is that the
key in your URLs never changes because a font vendor edited a metadata field.
Why not a full colour picker widget? A native colour input is reliable everywhere for choosing red, green and blue; the only thing it lacks is alpha. Pairing it with a slider costs a few lines and no dependency, where a hand-rolled saturation/value canvas would be a lot of code to maintain badly.
Why no facade? The services resolve cleanly from the container, and a facade added purely for symmetry would be one more thing to document and keep working.
Why is the driver behind an interface if only one exists? So the rendering
pipeline does not name Imagick anywhere, which is what would make a GD or
imagick-free driver an additive change rather than a rewrite.
What is deliberately not implemented? Multiple markers, custom marker icons, paths, polygons, overlays, multiple labels, retina output. The architecture leaves room for them — the tile grid, the driver interface and the options object all generalise — but adding public query parameters before they are needed is how an API surface becomes permanent by accident.
Licence
MIT. See LICENSE.
The bundled fonts keep their own licences, each included next to the font it
covers: Cairo, IBM Plex Sans Arabic, Inter and Noto Sans Arabic under the SIL
Open Font License 1.1, and DejaVu Sans under the Bitstream Vera and Arev
licences. See resources/fonts/LICENSES.md for
the per-font copyright notices and licence files.
The OFL requires its licence text to travel with the font, so those files are part of the package rather than a link in the documentation. If you add a font, add its licence alongside it.