gcgov/arcgisphpsdk

Open source framework for PHP interactions with Garrett County, Maryland's ArcGIS implementation

Maintainers

Package info

github.com/gcgov/arcgisphpsdk

pkg:composer/gcgov/arcgisphpsdk

Transparency log

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v2.2.0 2026-07-09 16:45 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

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/generateToken the 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 one config instance across the services you construct.
  • The token request is made with client=referer and a referer of https://apps.garrettcountymd.gov (see sdk\Token::get()).
  • development: true switches wrappers that support it (currently MajorMinorFeatureService) to their DEVELOPMENT_-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 — a FeatureProperties subclass 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->geometrysdk\Feature\Geometry with type and a coordinates array. 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:

  1. Schema sync (/cli/updateFeatureLayerDefinition) — reflects over the application's structure model. 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 via addFeatureLayerFields() / updateFeatureLayerFields().
  2. Feature sync (/cli/updateGisFeatures) — pulls all features with getAllFeatures(), matches them to Mongo documents by stored esriGlobalId, then: updates matched features (copying application values onto $feature->properties and rounding coordinates to 5 decimals), adds features for unmatched documents (persisting the returned objectId/globalId back to Mongo), and deletes features that no longer have a matching document.
  3. Reference-data pulls — road names and extents are imported from the centerlines layer (CenterLinesCulvertsFeatureService::getAllFeatures()), and the legacy Bridges_and_Culverts layer 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 FeatureLayer URLs are the same FeatureServer endpoints 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 as x = longitude, y = latitude.
  • Stable identifiersesriObjectId / esriGlobalId captured 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):

  1. Service wrappersrc/MyServiceFeatureService.php, extending sdk\FeatureService and 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;
	}

}
  1. Propertiessrc/MyServiceFeatureService/FeatureProperties.php extending sdk\FeatureProperties, with one nullable typed public property per layer field, named exactly as the field is named on the layer (check getFeatureServerLayer()->fields).

  2. Featuresrc/MyServiceFeatureService/Feature.php extending sdk\Feature; type the $properties property to your FeatureProperties, instantiate it in the constructor, and implement getProperties(), getObjectId(), and getGlobalId() against the layer's id fields.

  3. Querysrc/MyServiceFeatureService/FeatureQuery.php extending sdk\FeatureQuery, re-declaring $features so the docblock hydrates elements to your Feature class:

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.