PHPackages                             thtc/laravel-static-maps - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Image &amp; Media](/categories/media)
4. /
5. thtc/laravel-static-maps

ActiveLibrary[Image &amp; Media](/categories/media)

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.

v1.0.0(today)00MITPHPPHP ^8.3

Since Aug 25Pushed todayCompare

[ Source](https://github.com/THTC-DEV/laravel-static-maps)[ Packagist](https://packagist.org/packages/thtc/laravel-static-maps)[ RSS](/packages/thtc-laravel-static-maps/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (7)Versions (2)Used By (0)

Laravel Static Maps
===================

[](#laravel-static-maps)

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
--------

[](#contents)

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Imagery sources](#imagery-sources)
- [The endpoint](#the-endpoint)
- [Query parameters](#query-parameters)
- [POI text styling](#poi-text-styling)
- [Transparent colours](#transparent-colours)
- [Label position](#label-position)
- [Fonts](#fonts)
- [Examples](#examples)
- [Programmatic use](#programmatic-use)
- [Playground](#playground)
- [Caching](#caching)
- [Redis](#redis)
- [Nginx](#nginx)
- [Security](#security)
- [Performance](#performance)
- [How the marker is rendered](#how-the-marker-is-rendered)
- [POI labels](#poi-labels)
- [Arabic and other complex scripts](#arabic-and-other-complex-scripts)
- [Image guarantees](#image-guarantees)
- [Testing](#testing)
- [Development](#development)
- [Building the playground assets](#building-the-playground-assets)
- [Troubleshooting](#troubleshooting)
- [Imagery licensing and attribution](#imagery-licensing-and-attribution)
- [Design decisions](#design-decisions)
- [Licence](#licence)

Features
--------

[](#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 `#RRGGBB` still meaning fully opaque.
- **Fonts ship with the package and are discovered automatically** — drop a `.ttf` or `.otf` into 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-maxage` and strong `ETag`s, 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
------------

[](#requirements)

RequirementVersionNotesPHP8.3+Laravel12 or 13`ext-imagick`ImageMagick 6.9+ / 7.xRequired`ext-mbstring`—RequiredImagick 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](#design-decisions).

No font needs to be installed on the server: one is bundled.

Installation
------------

[](#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
-------------

[](#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

[](#environment-variables)

VariablePurpose`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 templateImagery sources
---------------

[](#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'),
    ],
],
```

KeyRequiredPurpose`label`noName shown in the playground dropdown`url`**yes**XYZ tile URL template`key`noServer-side credential — see [Credentials](#credentials)`key_parameter`noQuery parameter the credential is appended as, overriding the global defaultA 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](#security).

### Credentials

[](#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 nothingRendered into a generated URL**Never.** Generated URLs name the imagery source by key onlyShown in the playground**Never**Written to the log**Never.** Tile URLs pass through the sanitiser first, which redacts itIncluded in a cache key**Never.** Rotating the key does not invalidate a single cached imageReadable from the source object**No.** It is a private property, and is not serialisedA 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
------------

[](#the-endpoint)

```
GET {prefix}/{lat}/{lng}/{zoom}.png

```

With the default prefix:

```
GET /maps/static/21.543333/39.172778/15.png

```

SegmentRangeNotes`lat`−90 to 90Clamped to ±85.05112878 when projecting, since Web Mercator cannot represent the poles`lng`−180 to 180Wraps correctly across the antimeridian`zoom``zoom.min` to `zoom.max`Integer; 0–22 by defaultThe response is always `image/png`, served inline. There is no `Content-Disposition` header, so the URL works directly in an `` tag, as a CSS `background-image`, and from any API client.

Query parameters
----------------

[](#query-parameters)

ParameterTypeDefaultBounds`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`stringnoneUp 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

[](#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`Meaning404—The path is not a valid coordinate triple422`validation_failed`A parameter is missing, malformed or out of bounds422`invalid_imagery_source`The imagery key is not configured422`tile_limit_exceeded`The request needs more tiles than `limits.max_tiles`502`imagery_source_unavailable`The tile provider could not be used500`rendering_failed`Rendering failed; details are logged, not returnedPOI text styling
----------------

[](#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

```

ParameterControls`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

[](#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

[](#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

[](#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:

RequestedFace usedEmboldened400`DejaVuSans.ttf`no500`DejaVuSans.ttf`yes, slightly600`DejaVuSans-Bold.ttf`no700`DejaVuSans-Bold.ttf`noSynthetic 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

[](#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

[](#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
-------------------

[](#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
--------------

[](#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
-----

[](#fonts-1)

### Bundled, so nothing needs installing

[](#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 familyFacesLatinArabicForms-BNotesDejaVu Sans252/5238/43141/144The default; covers both scriptsIBM Plex Sans Arabic152/5243/43140/144The most complete coverage of bothCairo152/5238/4389/144Partial Forms-B; see belowNoto Sans Arabic10/5243/43140/144No Latin at all: mixed labels lose itInter152/520/431/144Latin only, shipped as OTFThe 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

[](#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

[](#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

[](#weights-1)

`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

[](#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
--------

[](#examples)

```

```

```
# 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.

```

```

Programmatic use
----------------

[](#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
----------

[](#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:

ControlParameterPOI 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

[](#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

[](#font-picker)

A native `` 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

[](#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
-------

[](#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

[](#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
-----

[](#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)

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)

Security was treated as a functional requirement rather than an afterthought. Each item below has tests covering it.

### SSRF

[](#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

[](#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 `">`, `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

[](#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

[](#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

[](#label-styling-injection)

None of the styling parameters accept free-form input:

- `poi-text-color` and `poi-bg-color` are 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-font` is matched against the configured keys with a strict comparison. Filenames, absolute paths, relative paths, traversal sequences, `file://` and `https://` URLs, `data:` URLs, CSS font stacks and `@font-face` rules 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-weight` is matched against a controlled list.
- `poi-font-size`, `poi-padding` and `poi-border-radius` are integers bounded by configuration.

### Resource exhaustion

[](#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-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

[](#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

[](#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

[](#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
-----------

[](#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-Match` costs 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
--------------------------

[](#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:

1. **It does not depend on an SVG delegate.** Hardened ImageMagick installations routinely disable the SVG coder in `policy.xml`, and some builds report `SVG`as 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.
2. **It is drawn at the requested size.** The outline is scaled as geometry, not as pixels, so `marker-size=128` is as crisp as `marker-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

[](#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-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](#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-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:

1. **Joining.** Each letter has up to four shapes depending on its neighbours, and the base codepoints carry no shape at all.
2. **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+FEFF` and `U+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:

InputRendered`جدة`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

[](#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

[](#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
----------------

[](#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_error` is enabled.
- Alpha is preserved (`png32`), and the virtual canvas left behind by cropping is reset so no viewer applies a phantom offset.

Testing
-------

[](#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; that `poi-font` rejects 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 `ff` produce 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](#development).

Assertions are made on image properties and pixels, never on binary equality, which would be brittle across ImageMagick builds.

Development
-----------

[](#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
------------------------------

[](#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
---------------

[](#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](#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](#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
---------------------------------

[](#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](https://operations.osmfoundation.org/policies/tiles/)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
----------------

[](#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](#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
-------

[](#licence)

MIT. See [LICENSE](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`](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.

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/23261109?v=4)[Ahmed Fathy](/maintainers/ahmed-aliraqi)[@ahmed-aliraqi](https://github.com/ahmed-aliraqi)

---

Top Contributors

[![ahmed-aliraqi](https://avatars.githubusercontent.com/u/23261109?v=4)](https://github.com/ahmed-aliraqi "ahmed-aliraqi (2 commits)")

---

Tags

laravelimagickmappngstatic mapstilesxyzweb-mercator

###  Code Quality

TestsPest

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/thtc-laravel-static-maps/health.svg)

```
[![Health](https://phpackages.com/badges/thtc-laravel-static-maps/health.svg)](https://phpackages.com/packages/thtc-laravel-static-maps)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

80427.1M249](/packages/laravel-mcp)[laravel/socialite

Laravel wrapper around OAuth 1 &amp; OAuth 2 libraries.

5.7k113.1M1.0k](/packages/laravel-socialite)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)[defstudio/telegraph

A laravel facade to interact with Telegram Bots

818355.4k3](/packages/defstudio-telegraph)[illuminate/auth

The Illuminate Auth package.

9328.5M1.4k](/packages/illuminate-auth)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
