Skip to main content

Immutability

View Markdown versionEdit this page on GitHub

Next version — This documentation describes a major version that is not yet the current stable release of spatial-types.

View the library roadmap

Spatial values are often reused in several places. Treating their coordinates as immutable makes that reuse safe and makes a value's location explicit.

Current contract​

Every spatial type is immutable through the public API. Its coordinates, spatial reference, and aggregate membership are set by its constructor, and it has no public setter or membership mutator. getCoordinates() returns an immutable Value\Coordinates value. withCoordinates() creates a distinct point with replacement coordinates, while withSpatialReference() creates a distinct point with the same coordinates and a different declared spatial reference.

use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point;
use LongitudeOne\SpatialTypes\Value\Coordinates;
use LongitudeOne\SpatialTypes\Reference\SpatialReference;

$paris = new Point(2.3522, 48.8566, 4326);
$movedParis = $paris->withCoordinates(Coordinates::xy(2.3600, 48.8566));
$parisInLambert93 = $paris->withSpatialReference(SpatialReference::epsg(2154));
$parisInLambert93 = $parisInLambert93->withCoordinates(
Coordinates::xy(652469.0227, 6862035.2594) // Calculated by an external coordinate transformer.
);

The original $paris remains unchanged. This is particularly useful when a point is part of an aggregate. withSpatialReference() only changes the declared reference; it never converts ordinates. The Lambert 93 values in the example must be calculated by an external coordinate transformer.

LineString, CircularString, MultiPoint, and Polygon provide an equally immutable point-replacement operation. withPoint() returns a deep copy: the source aggregate, its rings, and its points remain unchanged.

use LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\LineString;
use LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\MultiPoint;

$line = new LineString([[1, 2, 10], [3, 4, 20]], 2154);
$multiPoint = new MultiPoint([[1, 2, 10], [3, 4, 20]], 2154);
$movedLine = $line->withPoint(1, Coordinates::xym(5, 6, 30));
$movedMultiPoint = $multiPoint->withPoint(0, Coordinates::xym(5, 6, 30));

For a polygon, the ring index comes before the point index. A replacement of the first or final point of a ring is mirrored to the other endpoint, which preserves its closure.

$movedExterior = $polygon->withPoint(0, 0, Coordinates::xym(5, 6, 30));
$movedHole = $polygon->withPoint(1, -1, Coordinates::xym(7, 8, 40));

Polygon::withRing() and MultiLineString::withLineString() replace a complete ring or line string from coordinate tuples. They also return deep copies and preserve the source aggregate's family, dimension, and SRID.

$expandedPolygon = $polygon->withRing(0, [
[-1, -1], [11, -1], [-1, 11], [-1, -1],
]);
$updatedMultiLine = $multiLine->withLineString(1, [[5, 6], [7, 8]]);
$movedMultiLinePoint = $multiLine->withPoint(1, 0, Coordinates::xy(5, 6));

MultiPolygon follows the same rule at every nesting level: withPoint() selects a polygon, ring, and point; withRing() selects a polygon and ring; and withPolygon() selects a polygon. Every method returns a deep copy and preserves the family, dimension, and SRID.

$movedMultiPolygon = $multiPolygon->withPoint(0, 0, 0, Coordinates::xy(-1, -1));
$expandedMultiPolygon = $multiPolygon->withRing(1, 0, [[19, -1], [31, -1], [19, 11], [19, -1]]);
$replacedMultiPolygon = $multiPolygon->withPolygon(0, [
[[40, 0], [50, 0], [40, 10], [40, 0]],
]);

GeometryCollection and GeographyCollection use withElement() to replace one indexed element. The replacement follows the usual family, dimension, and SRID compatibility rules, while unchanged elements are deeply copied.

Why coordinates are immutable​

Consider a point used by a line string, where that line string is then used by a multi-line string:

use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString;
use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point;

$point = new Point(2.3522, 48.8566, 4326);
$line = new LineString([
$point,
new Point(2.3600, 48.8600, 4326),
], 4326);
$multiLine = new MultiLineString([$line], 4326);

$point is referenced by $line, and $line is referenced by $multiLine. If a public operation such as setX() could change $point, the line and the multi-line string would both change as an indirect side effect. A caller that only holds $multiLine could therefore observe a geometry change without any operation being made on it.

Because points are immutable, that operation does not exist. Code that needs another location creates another point and explicitly builds the spatial value that should contain it. This preserves validation of geography coordinate ranges, coordinate dimensions, family, and SRID compatibility.

Changing a spatial reference follows the same value-object rule: withSpatialReference() produces another spatial value. It does not transform coordinates; a coordinate transformation must first calculate new ordinates, then construct a value with those ordinates and the target SRID.

Coordinates value object​

Value\Coordinates is a public, immutable value object for a normalized point tuple. It accepts numbers only: parsing coordinate strings remains a constructor and factory concern because it depends on the point family. The value carries its dimension, so it cannot represent an XY point with a Z or M ordinate.

use LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\Point;
use LongitudeOne\SpatialTypes\Value\Coordinates;

$paris = new Point(2.3522, 48.8566, 28, 12, 4326);
$higherParis = $paris->withCoordinates(
Coordinates::xyzm(2.3522, 48.8566, 42, 12)
);

withCoordinates() requires the same dimension as the receiving point. It preserves its concrete class, family, and SRID, and still applies geography longitude/latitude validation when creating the replacement point.

Aggregate types​

The coordinate-replacement API applies to Point, LineString, MultiPoint, and Polygon (including Triangle). LineString::withPoint() and MultiPoint::withPoint() select a point; Polygon::withPoint() selects a ring then a point. All use the immutable Value\Coordinates value, so a replacement must match the receiver's coordinate dimension.

Polygon::withRing() selects a ring and requires coordinates forming a closed ring. MultiLineString::withPoint() selects a line string then a point, while MultiLineString::withLineString() selects one of its line strings. Both derive the expected coordinate layout from the receiver.

MultiPolygon::withPoint() selects a polygon, ring, then point. MultiPolygon::withRing() selects a polygon then ring, while MultiPolygon::withPolygon() replaces an entire polygon. Each operation creates a deep copy of every polygon in the collection. PolyhedralSurface uses patch indexes with withPoint(), withRing() and withPatch(), and revalidates the complete surface after each replacement. Its withArrayOfCoordinates() replaces all patches atomically, or returns an empty surface for []. Indexed replacements on an empty surface are invalid. Neighbouring faces are never changed implicitly when editing a shared vertex.

GeometryCollection::withElement() and GeographyCollection::withElement() replace one element and deeply copy every unchanged element.

Aggregate membership is fixed at construction. Their plural getters return PHP arrays, so changing a returned array cannot change the aggregate; the objects inside it are also immutable. Calling withSpatialReference() on an aggregate creates a deep copy whose descendants all receive the requested reference.

See Instantiable spatial types for the complete current mutability contract.

Circular strings also provide withArrayOfCoordinates(); every replacement revalidates the odd point count (at least three when non-empty), non-empty members and distinct intermediate points. Complete-circle endpoints may coincide, and collinear arcs remain valid. Reference replacements preserve the defining coordinates and copy the contained points. Editing an endpoint does not automatically edit the other end of a closed circular string.

Compound curves retain immutable ordered curve components. Their withSpatialReference() and legacy withSrid() operations deeply copy all components and defining points, preserving interpolation and validating continuity. Construct a new compound from replacement curves to change its path; modifying a returned getCurves() array does not affect the original.