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.
- 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)
composer require gcgov/arcgisphpsdkProduction 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 }
}
]
}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.
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
}\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 );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.
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.
$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;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.
$deleteResponse = $service->deleteFeatures( $featuresToDelete );Deletion is by object id — each passed feature must have its objectid populated (getObjectId()); features without one are skipped.
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).
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}/updateDefinitionBoth 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()).
$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, templatesBoth 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', ... ]\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, ...)
}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).
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.
MIT — see LICENSE.