opengis/laravel-geoserver
Laravel GeoServer integration for managing GeoServer from your Laravel app. Provides a fluent client for workspaces, data stores, layers, styles, and REST API operations, with configuration and Laravel-friendly helpers for publishing and maintaining geospatial services.
Install the package into an existing Laravel app and publish its configuration:
composer require opengis/laravel-geoserver
php artisan vendor:publish --tag=geoserver-config
Add the connection details to .env, then mirror them in config/geoserver.php:
GEOSERVER_URL=https://gis.example.com/geoserver
GEOSERVER_USERNAME=admin
GEOSERVER_PASSWORD=geoserver
GEOSERVER_WORKSPACE=app
Look at config/geoserver.php first — that is where the base URL, credentials, default workspace, and HTTP client options (timeouts, retries, verify TLS) live. Everything else in the package reads from it, so getting it right avoids the majority of "why is this 404/401" moments.
Verify connectivity before writing any code:
php artisan tinker
>>> GeoServer::workspaces()->all();
If you get a ConnectionException, the base URL is wrong or the container is unreachable from PHP. If you get 401, it is credentials or (more often) a reverse proxy stripping the Authorization header. If you get 404, check whether your GeoServer is mounted under /geoserver and whether the URL includes a trailing slash — set the base URL without one and let the package join paths.
The first realistic use case: publish a PostGIS table as a layer and consume it from a map. In practice that means (1) ensure a workspace exists, (2) register a PostGIS data store pointing at your app's database, (3) create a feature type from an existing table, (4) confirm the layer appears in GetCapabilities, (5) wire the WMS/WFS URL into a Leaflet/OpenLayers front end. Do that once by hand in tinker before you encapsulate it in a command — the package will happily create half-configured stores, and the feedback loop is much faster interactively.
Inject the client, don't reach for the facade everywhere. The facade is convenient in controllers and one-off commands, but resolve the underlying client from the container in services and jobs. That keeps GeoServer calls mockable and makes it obvious which classes depend on the GIS backend.
Wrap GeoServer in a domain service. Rather than sprinkling GeoServer::layers()->create(...) across controllers, create something like App\Gis\LayerPublisher that owns the naming conventions ({workspace}:{table}), the store name, and the SRS. This is where you absorb GeoServer's quirks so the rest of the app never sees them.
Provision from migrations or a dedicated command, not from HTTP requests. Creating workspaces, stores, and styles is slow, occasionally flaky, and should be idempotent. Put it in an artisan command (geoserver:sync) that is safe to re-run, then call it from deploy scripts or a scheduled task.
// app/Console/Commands/SyncGeoServer.php (sketch)
foreach (config('gis.layers') as $table => $meta) {
$this->geoServer->ensureWorkspace(config('geoserver.workspace'));
$this->geoServer->ensurePostgisStore($table, config('geoserver.workspace'));
$this->geoServer->publishFeatureType($table, [
'srs' => $meta['srs'] ?? 'EPSG:4326',
'title' => $meta['title'],
]);
}
Treat layer definitions as configuration, not code. A config/gis.php array of tables, titles, SRS, and styles lets you add layers without touching the publisher. It also gives you a single place to diff environments.
Cache metadata aggressively, invalidate on publish. GetCapabilities and per-layer metadata are expensive and rarely change. Cache them with a tagged key (geoserver:layer:{name}) and flush the tag inside the publish/unpublish methods. Never call GetCapabilities on a page render.
Separate read and write concerns. WMS/WFS requests from browsers should hit GeoServer directly (or through a thin proxy), not proxied through Laravel — GeoServer streams tiles and GML far better than PHP does. Use this package for administration (REST) and keep the map traffic out of your app server.
Use queued jobs for anything that touches styles or shapefile uploads. Style uploads and file-based stores involve multipart bodies and can take seconds. Dispatch a job, record the outcome on a local model (GisLayer, GisPublication), and let the UI poll or listen for a broadcast.
Keep a local mirror of published layers. A small gis_layers table (name, workspace, store, srs, published_at, checksum) makes it trivial to list layers in the admin UI without hitting GeoServer, and lets you reconcile drift with a geoserver:diff command.
Test with Http::fake() against recorded payloads. The package ultimately speaks HTTP, so fake the GeoServer endpoints and assert the request bodies (workspace name, store connection parameters, SRS). Keep one optional integration test that runs against a real GeoServer container in CI, tagged @integration, so the suite stays fast locally but you still catch real API drift.
Version the GeoServer instance. Pin the GeoServer image in docker-compose.yml. REST shapes differ between 2.20 and 2.25 (store types, style uploads), and "works on my machine" here usually means a different server minor version.
Idempotency is not guaranteed. Creating a workspace or store that already exists often returns 500 with a plain-text body rather than 409. Wrap creation in an existence check, or catch and inspect the response body — do not rely on status codes alone. This is the single biggest source of flaky deploy scripts.
Errors come back as HTML or XML. Even with Accept: application/json, GeoServer sometimes returns an HTML error page (especially behind a proxy or on 500s). Always log the raw body on failure; the package's exception message may be generic.
Trailing slashes and path joining. A base URL of https://host/geoserver/ plus /rest/workspaces can produce //rest. Set the base URL without a trailing slash and verify the first request in the HTTP client log.
Basic auth and reverse proxies. Nginx/Apache often strips Authorization when proxying, and GeoServer's admin UI uses sessions with CSRF tokens that the REST client does not. If you are mixing UI and API access, keep them on separate hostnames or confirm proxy_set_header Authorization $http_authorization; is set.
Workspace-prefixed names. Layers are addressed as workspace:layer, stores as workspace:store. Forgetting the prefix gives confusing 404s. Centralise the prefixing in your publisher service so no caller builds the string by hand.
Store connection parameter names are strict and version-specific. PostGIS stores expect host, port, database, schema, user, passwd (not password), and dbtype. A typo returns a 500 without naming the offending field. Build the parameter array once, in a config-driven factory, and test it.
Caches make config changes invisible. After creating a store or style, GeoServer may serve stale capabilities for minutes. Call the reload/reset endpoint (/rest/reload) after publish, or disable the resource cache in test environments — otherwise your "layer not appearing" bug is just a cache, and you will spend an hour on it.
SRS mismatches are silent. Declaring EPSG:4326 on data that is actually EPSG:3857 produces layers that render in the wrong place but return 200. Validate the native CRS from the database (ST_SRID) before publishing, and store it on your mirror table.
Large GetCapabilities documents will blow up memory. With many layers, the XML can be several megabytes. Use streaming or query the REST layer list (/rest/layers.json) instead, and never dump capabilities into a Blade view for debugging in production.
Shapefile and style uploads need special handling. File-based stores require a zip in a specific structure and a multipart upload with the correct filename; style uploads use PUT with Content-Type: application/vnd.ogc.sld+xml. If the upload fails silently, check the multipart Content-Disposition filename — GeoServer uses it as the resource name.
Configure timeouts and retries deliberately. The default HTTP timeout is often too short for publish operations and too long for health checks. Set a short connect timeout and a longer read timeout in the config, and retry only idempotent GETs — retrying a POST that actually succeeded is how you get duplicate stores.
config:cache will freeze your env. Because the package reads config('geoserver.*'), any runtime .env change requires php artisan config:clear. In containerised deploys, ensure the config cache is rebuilt on release, not baked into the image with stale credentials.
Extension points worth knowing. Use the package's HTTP client factory or Laravel's Http macros to inject custom headers (tenant IDs, tracing) and Guzzle middleware. If you need to support a GeoServer extension the package does not model, drop down to the raw client's request() method rather than forking the package — keep custom REST calls in your own GeoServerExtras class so upgrades stay cheap.
How can I help you explore Laravel packages today?