Skip to content

Latest commit

 

History

33 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

arcgisphpsdk

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.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages