gcgov / arcgisphpsdk
Open source framework for PHP interactions with Garrett County, Maryland's ArcGIS implementation
Requires
- php: >=8.1
- andrewsauder/json-deserialize: ^3.0
- guzzlehttp/guzzle: ^7.8
This package is auto-updated.
Last update: 2026-07-10 12:11:31 UTC
README
An open source PHP SDK for interacting with Garrett County, Maryland's ArcGIS Enterprise implementation. It wraps the ArcGIS REST API with strongly typed PHP classes so server-side applications can authenticate, query, create, update, and delete features on hosted feature services, manage layer schemas (fields and coded-value domains), and geocode addresses.
The SDK is the server-side half of Garrett County's GIS integrations: PHP applications use it to keep their own databases in sync with hosted ArcGIS feature layers, while browser applications render those same layers with the ArcGIS Maps SDK for JavaScript (@arcgis/core). The county's bridges application (gcgov/bridge-api + gcgov/bridge-app) is the reference implementation and the source of the examples in this document.
Requirements
- PHP >= 8.1
- guzzlehttp/guzzle ^7.8 (HTTP client)
- andrewsauder/json-deserialize ^3.0 (typed JSON hydration/serialization — every model in this SDK extends it)
Installation
composer require gcgov/arcgisphpsdk
Production applications pin a tagged release (for example "gcgov/arcgisphpsdk": "^2.0"). During local development, consuming applications typically require dev-main and register the SDK as a symlinked path repository so edits take effect immediately:
{
"require": {
"gcgov/arcgisphpsdk": "dev-main"
},
"repositories": [
{
"type": "path",
"url": "C:/inetpub/www/packages/arcgis/",
"options": { "symlink": true }
}
]
}
Architecture at a glance
Classes autoload from src/ with PSR-4 prefix gcgov\arcgis\. The code is split into two layers:
| Namespace | Purpose |
|---|---|
gcgov\arcgis\sdk\* |
Generic ArcGIS REST machinery: configuration, token generation, the FeatureService base class (query/add/update/delete/schema operations), the LocatorService base class (geocoding), typed models for service and layer metadata, and typed response models. |
gcgov\arcgis\* (root) |
Garrett County–specific service wrappers bound to specific published services. Each wrapper pairs with a sub-namespace of typed Feature / FeatureProperties / FeatureQuery classes that mirror that layer's schema. |
Prebuilt service wrappers:
| Class | ArcGIS service | Layer | Notes |
|---|---|---|---|
\gcgov\arcgis\MajorMinorFeatureService |
Hosted/Major_and_Minor_Structures/FeatureServer |
0 | The bridges application's system-of-record layer. When config has development=true, targets Hosted/DEVELOPMENT_Major_and_Minor_Structures instead. |
\gcgov\arcgis\BridgesCulvertsFeatureService |
Hosted/Bridges_and_Culverts/FeatureServer |
0 | Legacy bridges/culverts inventory layer; used for one-time imports into the bridges application. |
\gcgov\arcgis\CenterLinesCulvertsFeatureService |
Addresses/Garrett_Centerlines/FeatureServer |
1 | County road centerlines (Maryland SHA schema, uppercase field names). Includes getAllRoadNames(). |
\gcgov\arcgis\DPUWaterSystemEditorFeatureService |
Hosted/DPU_Water_System_Editor/FeatureServer |
21 layers + 5 tables | Department of Public Utilities water system assets (hydrants, valves, meters, mains, boundaries, inspection tables). One typed getAll…Features() method and a LAYER_*/TABLE_* id constant per sublayer and table. When config has development=true, targets Hosted/DEVELOPMENT_DPU_Water_System_Editor instead and transparently translates the LAYER_*/TABLE_* constants to that service's re-sequenced layer ids (0–25). |
\gcgov\arcgis\AddressLocator |
Addresses/Garrett_Address_Locator/GeocodeServer |
— | Address geocoding via findAddressCandidates. |
Every model extends andrewsauder\jsonDeserialize\jsonDeserialize, which gives it:
ClassName::jsonDeserialize( $json )— build a typed instance graph from a JSON string or Guzzle response body. Array properties are hydrated to the class named in the property's/** @var Type[] */docblock.- Serialization back to JSON via
json_encode(), with#[excludeJsonSerialize]/#[excludeJsonDeserialize]attributes and_afterJsonSerialize()/_afterJsonDeserialize()hooks to shape the payload.
Quick start
use gcgov\arcgis\config; use gcgov\arcgis\MajorMinorFeatureService; // baseUrl, username, password, development flag $config = new config( 'https://gis.garrettcounty.org/', 'username', 'password', false ); $service = new MajorMinorFeatureService( $config ); // fetch every feature on the layer (automatically pages through transfer limits) $features = $service->getAllFeatures(); foreach( $features as $feature ) { // $feature->properties is typed to the layer schema echo $feature->properties->countystructurenumber . ' — ' . $feature->properties->roadname . PHP_EOL; // geometry is GeoJSON: coordinates are [ longitude, latitude ] in WGS84 $coordinates = $feature->geometry?->coordinates; }
All SDK methods throw \GuzzleHttp\Exception\GuzzleException (transport failures) and \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException (unexpected response shape); wrap calls accordingly:
try { $features = $service->getAllFeatures(); } catch( \GuzzleHttp\Exception\GuzzleException | \andrewsauder\jsonDeserialize\exceptions\jsonDeserializeException $e ) { // log and handle }
Configuration and authentication
\gcgov\arcgis\config carries the portal base URL, credentials, and a development flag:
$config = new \gcgov\arcgis\config( baseUrl: 'https://gis.garrettcounty.org/', // default username: 'serviceAccount', password: 'secret', development: false );
config::getToken()lazily requests a token from{baseUrl}portal/sharing/rest/generateTokenthe first time it is needed, caches it on the config instance, and automatically requests a fresh one when the cached token expires. Every request the SDK makes appends this token, so you never handle tokens directly — just share oneconfiginstance across the services you construct.- The token request is made with
client=refererand a referer ofhttps://apps.garrettcountymd.gov(seesdk\Token::get()). development: trueswitches wrappers that support it (currentlyMajorMinorFeatureService) to theirDEVELOPMENT_-prefixed service so you can exercise edits without touching production data. Consuming applications typically pass their own "is local environment" flag here.
Real-world construction from bridge-api, where connection settings live in the app's environment config:
$arcgisServiceConfig = new \gcgov\arcgis\config( config::getEnvironmentConfig()->appDictionary['arcgisBaseUrl'], config::getEnvironmentConfig()->appDictionary['arcgisUsername'], config::getEnvironmentConfig()->appDictionary['arcgisPassword'], config::getEnvironmentConfig()->isLocal() ); $majorMinorFeatureService = new \gcgov\arcgis\MajorMinorFeatureService( $arcgisServiceConfig );
Reading features
Each service wrapper provides getAllFeatures() — or, for multi-layer services, one getAll…Features() method per sublayer — which queries the layer with f=geojson, where=1=1, and outFields=*, requesting 1,000 records per call and recursing with resultOffset while the response reports exceededTransferLimit. The merged result is an array of typed Feature objects:
$roadsService = new \gcgov\arcgis\CenterLinesCulvertsFeatureService( $config ); /** @var \gcgov\arcgis\CenterLinesFeatureService\Feature[] $roads */ $roads = $roadsService->getAllFeatures(); // convenience helper: distinct, sorted local road names $roadNames = $roadsService->getAllRoadNames();
Multi-layer services expose one typed method and one layer id constant per sublayer and table. The constants are also how you target a sublayer in edit and schema operations. The constants always hold the production layer ids — in development mode the service translates them to the development copy's ids on every request, so consuming code never branches on environment:
use gcgov\arcgis\DPUWaterSystemEditorFeatureService; $waterSystemService = new DPUWaterSystemEditorFeatureService( $config ); /** @var \gcgov\arcgis\DPUWaterSystemEditorFeatureService\FireHydrant\Feature[] $hydrants */ $hydrants = $waterSystemService->getAllFireHydrantFeatures(); $updateResponse = $waterSystemService->updateFeatures( $hydrants, DPUWaterSystemEditorFeatureService::LAYER_FIRE_HYDRANT ); // tables hydrate the same way — rows are Features with null geometry, and edits post attribute-only payloads $inspections = $waterSystemService->getAllHydrantMaintenanceInspectionFeatures();
Layers that are searched by attribute also expose a where-filtered variant that pages exactly like getAll…Features() but sends your ArcGIS SQL where clause instead of 1=1 (escape embedded single quotes by doubling them):
/** @var \gcgov\arcgis\DPUWaterSystemEditorFeatureService\ServiceMeter\Feature[] $meters */ $meters = $waterSystemService->getServiceMeterFeaturesWhere( "meterid='81234567'" );
A Feature follows the GeoJSON shape:
$feature->properties— aFeaturePropertiessubclass with one typed public property per layer field. Property names match the layer's field names exactly (lowercase snake_case on the county's hosted layers, e.g.county_number; uppercase on the SHA centerlines schema, e.g.RDNAMELOCAL). Date fields arrive as epoch milliseconds.$feature->geometry—sdk\Feature\Geometrywithtypeand acoordinatesarray. Coordinate order is GeoJSON[ longitude, latitude ], spatial reference WGS84 (wkid 4326). Line features carry nested coordinate arrays.$feature->getObjectId()/$feature->getGlobalId()— the ESRI identifiers, read from the layer's objectid/globalid fields.
Editing features
sdk\FeatureService exposes addFeatures(), updateFeatures(), and deleteFeatures(), each accepting an array of Feature objects and an optional layer id (default 0). Internally the SDK converts each GeoJSON-shaped Feature into the ESRI edit format ({ attributes: {...}, geometry: { x, y, spatialReference: { wkid: 4326 } } }) via sdk\AddFeature::fromFeature() / sdk\UpdateFeature::fromFeature(). Features without coordinates serialize with no geometry, making an attribute-only edit.
Add
$feature = new \gcgov\arcgis\MajorMinorFeatureService\Feature(); $feature->properties->roadname = 'Bumble Bee Road'; $feature->properties->countystructurenumber = 'G-0042'; $feature->geometry->coordinates = [ -79.344935, 39.522503 ]; // [ lon, lat ] $addResponse = $service->addFeatures( [ $feature ] );
Check both the response-level error and each per-feature result — this is the pattern bridge-api uses, including capturing the new ESRI ids back onto its own record so future syncs can match:
if( isset( $addResponse->error ) ) { throw new \Exception( $addResponse->error->message ?? 'Top level error' ); } elseif( !isset( $addResponse->addResults[0] ) ) { throw new \Exception( 'No result returned' ); } elseif( isset( $addResponse->addResults[0]->error ) ) { throw new \Exception( $addResponse->addResults[0]->error->description ?? 'Feature error' ); } $structure->esriObjectId = $addResponse->addResults[0]->objectId; $structure->esriGlobalId = $addResponse->addResults[0]->globalId;
Update
Fetch existing features, mutate their typed properties, and send them back. updateFeatures() matches on the feature's objectid:
$features = $service->getAllFeatures(); foreach( $features as $feature ) { if( $feature->properties->globalid === $myRecord->esriGlobalId ) { $feature->properties->rating = '7'; $feature->properties->roadname = $myRecord->roadName; $feature->geometry->coordinates[0] = round( $myRecord->longitude, 5 ); $feature->geometry->coordinates[1] = round( $myRecord->latitude, 5 ); $updateResponse = $service->updateFeatures( [ $feature ] ); } }
$updateResponse->updateResults contains a per-feature success flag and optional error, exactly like add results.
Delete
$deleteResponse = $service->deleteFeatures( $featuresToDelete );
Deletion is by object id — each passed feature must have its objectid populated (getObjectId()); features without one are skipped.
Attachments
For layers published with hasAttachments, files can be attached to a feature by object id (POST {layer}/{objectId}/addAttachment) and listed back (GET {layer}/{objectId}/attachments):
$attachmentInfos = $service->getAttachmentInfos( $objectId, $layerId ); foreach( $attachmentInfos->attachmentInfos ?? [] as $attachmentInfo ) { echo $attachmentInfo->id . ': ' . $attachmentInfo->name . ' (' . $attachmentInfo->contentType . ', ' . $attachmentInfo->size . ' bytes)' . PHP_EOL; } $addResponse = $service->addAttachment( $objectId, '/path/to/photo.jpg', 'photo.jpg', 'image/jpeg', $layerId ); if( $addResponse->error!==null || !( $addResponse->addAttachmentResult?->success ?? false ) ) { // handle failure: $addResponse->error?->message or $addResponse->addAttachmentResult?->error?->description }
addAttachment() streams the file as a multipart upload; the attachment name defaults to the file's base name and the mime type part is omitted when $contentType is null. ArcGIS does not deduplicate attachments — upload with deterministic names and check getAttachmentInfos() first when an operation may run more than once (see the utility-account-lookup-api meter replacement sync).
Managing layer schema (fields and domains)
The FeatureService base class can administer the layer definition through the ArcGIS admin REST endpoint. bridge-api uses this to project its application model onto the GIS layer: it reflects over its own classes and creates/updates one ESRI field per property, including coded-value domains for dropdown-style fields.
Inspect the current schema:
$layer = $service->getFeatureServerLayer( 0 ); foreach( $layer->fields as $field ) { echo $field->name . ' (' . $field->type . ')' . PHP_EOL; if( $field->domain !== null ) { foreach( $field->domain->codedValues as $codedValue ) { echo ' ' . $codedValue->code . ' => ' . $codedValue->name . PHP_EOL; } } }
Add or update fields:
use gcgov\arcgis\sdk\FeatureServerLayer\CodedValue; use gcgov\arcgis\sdk\FeatureServerLayer\Domain; use gcgov\arcgis\sdk\FeatureServerLayer\Field; $domain = new Domain(); $domain->name = 'structure_rating'; $domain->type = 'codedValue'; $domain->splitPolicy = 'esriSPTDefaultValue'; $domain->mergePolicy = 'esriMPTDefaultValue'; $domain->codedValues = []; $codedValue = new CodedValue(); $codedValue->code = '7'; $codedValue->name = 'Good'; $domain->codedValues[] = $codedValue; $field = new Field(); $field->name = 'rating'; $field->alias = 'Current Overall Rating'; $field->type = 'esriFieldTypeString'; // esriFieldTypeDouble, esriFieldTypeInteger, esriFieldTypeDate, ... $field->length = 256; $field->nullable = true; $field->editable = true; $field->domain = $domain; $addDefinition = $service->addFeatureLayerFields( [ $field ], 0 ); // POST {layer}/addToDefinition $updateDefinition = $service->updateFeatureLayerFields( [ $field ], 0 ); // POST {layer}/updateDefinition
Both return a response with success and error. addFeatureLayerFields() additionally returns domainMap entries — when ArcGIS has to rename a requested domain (name collision), createdDomainName differs from originalDomainName, and the caller should persist the created name for future lookups (see bridge-api's gisIntegration::updateFeatureLayerDefinition()).
Service metadata
$featureServer = $service->getFeatureServer(); // service-level metadata: layers, extents, capabilities foreach( $featureServer->layers as $layer ) { echo $layer->id . ': ' . $layer->name . ' (' . $layer->geometryType . ')' . PHP_EOL; } $layer = $service->getFeatureServerLayer( 0 ); // layer-level metadata: fields, indexes, renderer, templates
Both calls cache their result on the service instance; pass forceFetch: true to re-request (for example after changing the schema — updateFeatureLayerFields() does this automatically).
FeatureServerLayer also provides lookup helpers for translating human readable values into stored codes (all name matching is case-insensitive):
$field = $layer->getField( 'lifecyclestatus' ); // ?Field — includes type, length, alias, domain $domain = $layer->getFieldCodedValueDomain( 'lifecyclestatus' ); // ?Domain — field-level domain, or the subtype domain when the field defines none $code = $domain?->getCodedValueByName( 'In Service' )?->code; // '8' — cast to int for integer fields $validNames = $domain?->getCodedValueNames(); // [ 'Unknown', 'Proposed', 'In Service', ... ]
Geocoding
\gcgov\arcgis\AddressLocator wraps the county's geocode service. Set any of the standard findAddressCandidates parameters as public properties, then call findAddressCandidates() — every non-null property becomes a query string parameter:
$locator = new \gcgov\arcgis\AddressLocator( $config ); $locator->singleLine = '203 S 4th St, Oakland, MD 21550'; $locator->maxLocations = 5; $response = $locator->findAddressCandidates(); foreach( $response->candidates as $candidate ) { echo $candidate->address . ' (score ' . $candidate->score . '): ' . $candidate->location->x . ', ' . $candidate->location->y . PHP_EOL; // $candidate->attributes carries the full locator output (Match_addr, City, Postal, X/Y, ...) }
How the bridges application uses this SDK (reference integration)
The bridges asset-management system demonstrates the intended full-stack pattern.
Server side (bridge-api, PHP): MongoDB is the system of record for structure documents; the Major_and_Minor_Structures hosted layer is the GIS projection of that data. Scheduled CLI endpoints keep the two in sync:
- Schema sync (
/cli/updateFeatureLayerDefinition) — reflects over the application'sstructuremodel. Properties tagged with an#[esriField]attribute map to ESRI fields (deriving field name, type, and alias from the PHP property); properties tagged with a category attribute get coded-value domains built from the application's category/type tables. Fields are then created or updated on layer 0 viaaddFeatureLayerFields()/updateFeatureLayerFields(). - Feature sync (
/cli/updateGisFeatures) — pulls all features withgetAllFeatures(), matches them to Mongo documents by storedesriGlobalId, then: updates matched features (copying application values onto$feature->propertiesand rounding coordinates to 5 decimals), adds features for unmatched documents (persisting the returnedobjectId/globalIdback to Mongo), and deletes features that no longer have a matching document. - Reference-data pulls — road names and extents are imported from the centerlines layer (
CenterLinesCulvertsFeatureService::getAllFeatures()), and the legacyBridges_and_Culvertslayer was imported one-time into Mongo the same way.
Client side (bridge-app, Vue 3 + @arcgis/core ^4.29): the browser never talks to the PHP SDK — it renders the same ArcGIS services directly with the ArcGIS Maps SDK for JavaScript, and plots application data (structure coordinates served by bridge-api from Mongo) as graphics:
import esriConfig from "@arcgis/core/config.js"; import Map from "@arcgis/core/Map"; import FeatureLayer from "@arcgis/core/layers/FeatureLayer"; import Graphic from "@arcgis/core/Graphic"; import GraphicsLayer from "@arcgis/core/layers/GraphicsLayer"; esriConfig.portalUrl = "https://gis.garrettcounty.org/portal"; // the same centerlines service the PHP SDK reads server-side const centerlines = new FeatureLayer({ url: "https://gis.garrettcounty.org/server/rest/services/Addresses/Garrett_Centerlines/FeatureServer/1", }); map.add(centerlines); // application records (synced to GIS by the PHP SDK) drawn from their stored coordinates const graphicsLayer = new GraphicsLayer(); graphicsLayer.add(new Graphic({ geometry: { type: "point", longitude: structure.coordinates[0], latitude: structure.coordinates[1] }, symbol: { type: "simple-marker", color: [226, 119, 40] }, }));
The contract that makes the two halves line up:
- Same services — the JS
FeatureLayerURLs are the sameFeatureServerendpoints the PHP wrappers are bound to. - Same coordinates — both sides use WGS84 (wkid 4326) with GeoJSON
[ longitude, latitude ]ordering; the PHP SDK writes point geometry asx = longitude,y = latitude. - Stable identifiers —
esriObjectId/esriGlobalIdcaptured at add time let either side correlate a GIS feature with an application record. - Domains as dropdowns — coded-value domains written by the schema sync drive both ArcGIS pop-ups/editors and the application's own select lists (the app stores each type's
esriCode).
Adding support for a new feature service
Wrapping another published service is a four-file pattern (see src/MajorMinorFeatureService* for the canonical example):
- Service wrapper —
src/MyServiceFeatureService.php, extendingsdk\FeatureServiceand hard-coding the service and admin URLs (branch on$config->isDevelopment()if a development copy of the service exists):
namespace gcgov\arcgis; use gcgov\arcgis\sdk\FeatureService; class MyServiceFeatureService extends FeatureService { public function __construct( config $config ) { parent::__construct( $config, $config->getBaseUrl( 'server/rest/services/Hosted/My_Service/FeatureServer/' ), $config->getBaseUrl( 'server/rest/admin/services/Hosted/My_Service/FeatureServer/' ) ); } /** @return \gcgov\arcgis\MyServiceFeatureService\Feature[] */ public function getAllFeatures( int $offset = 0, int $featuresPerCall = 1000, array $collections = [] ): array { $token = $this->getConfig()->getToken(); $url = $this->getServiceUrl( '0/query?f=geojson&where=1%3D1&outFields=*&resultOffset=' . $offset . '&resultRecordCount=' . $featuresPerCall . '&token=' . $token ); $client = new \GuzzleHttp\Client(); $res = $client->request( 'GET', $url ); $collection = \gcgov\arcgis\MyServiceFeatureService\FeatureQuery::jsonDeserialize( $res->getBody() ); $collections[] = $collection; if( $collection->exceededTransferLimit ) { return $this->getAllFeatures( $offset + $featuresPerCall, $featuresPerCall, $collections ); } $features = []; foreach( $collections as $collection ) { $features = array_merge( $features, $collection->features ); } return $features; } }
-
Properties —
src/MyServiceFeatureService/FeatureProperties.phpextendingsdk\FeatureProperties, with one nullable typed public property per layer field, named exactly as the field is named on the layer (checkgetFeatureServerLayer()->fields). -
Feature —
src/MyServiceFeatureService/Feature.phpextendingsdk\Feature; type the$propertiesproperty to yourFeatureProperties, instantiate it in the constructor, and implementgetProperties(),getObjectId(), andgetGlobalId()against the layer's id fields. -
Query —
src/MyServiceFeatureService/FeatureQuery.phpextendingsdk\FeatureQuery, re-declaring$featuresso the docblock hydrates elements to yourFeatureclass:
namespace gcgov\arcgis\MyServiceFeatureService; class FeatureQuery extends \gcgov\arcgis\sdk\FeatureQuery { /** @var \gcgov\arcgis\MyServiceFeatureService\Feature[] $features */ public ?array $features = []; }
The /** @var ... */ docblock on $features is what tells the deserializer which class to hydrate — without it (or with the wrong class name) features will not be typed correctly.
License
MIT — see LICENSE.