Skip to content

The shapes

The fountain now has a position and a coordinate system to interpret it in, chapter 1 covered both. That is enough to describe a single point. It is not enough to describe everything else this project puts on a map.

A ride is not one position, it is a thread of them, in order, from where a rider clipped in to where they stopped. A region is not a position either, it is an area, an outline enclosing everywhere that counts as "inside". Three different ideas, and it would be reasonable to expect three different kinds of database column. There are not. This project uses exactly three shapes, Point, LineString and Polygon, and every geometry anywhere in this codebase is one of them or one of their Multi- forms, which get their own section below. This chapter is what those three words mean, how each one is stored, and how a shape travels from the database to the browser as text.

Point

A Point is one coordinate pair. Nothing else. It is the simplest shape there is, and it is exactly what the fountain needs: 50.4851, 5.8983 is already a complete description of where it is.

Nearly every catalog item in this project, every fountain, every repair stand, every viewpoint, is stored as a Point. See web/migrations/Version20260703153611.php, which creates the item table:

CREATE TABLE item (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, letter VARCHAR(1) NOT NULL, name VARCHAR(200) NOT NULL, geom geometry(Geometry, 4326) NOT NULL,
…

one geometry value per row. For most letters that value is a Point. The exception is the road-surface layer, letter A, which stores one LineString per mapped stretch of road in the very same column; chapter 9 (routes.md) reads those back out to estimate what a route is ridden on. Nothing in the declaration pins the column to one shape, and the next sections are about why.

LineString

A LineString is an ordered list of points, joined into a path. "Ordered" is doing real work in that sentence: a LineString is not just a bag of coordinates, it is those coordinates in a sequence, and the sequence is part of what it means. Reverse the list and you have not described the same shape read backwards, you have described riding the same road the other way.

A recommended route is stored as a LineString: the ordered points of the ride, from start to finish. See the same migration, web/migrations/Version20260703153611.php, which also creates recommended_route:

CREATE TABLE recommended_route (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(200) NOT NULL, geom geometry(Geometry, 4326) NOT NULL,
…

Polygon

A Polygon is a closed ring of points, the first point and the last point are the same one, so the outline joins up into an area rather than trailing off into an open path, optionally with one or more inner rings cut out of it as holes (a region with a lake excluded from it, for instance).

A region's outline is a Polygon in shape: the boundary of Wallonia, or of a Belgian province, is one closed ring. This project stores it in the Multi- form introduced below, so every region.geom row, Wallonia's included, is a MultiPolygon even when it holds a single ring. See web/migrations/Version20260703152605.php, which creates region:

CREATE TABLE region (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, slug VARCHAR(80) NOT NULL, name VARCHAR(160) NOT NULL, geom geometry(Geometry, 4326) DEFAULT NULL,
…

A hole is worth seeing once rather than only reading about, because the shape of the JSON is the whole idea: a Polygon's coordinates are a list of rings, and every ring after the first is a hole cut out of the one before them.

{
  "type": "Polygon",
  "coordinates": [
    [[4.0, 50.0], [4.1, 50.0], [4.1, 50.1], [4.0, 50.1], [4.0, 50.0]],
    [[4.04, 50.04], [4.06, 50.04], [4.06, 50.06], [4.04, 50.06], [4.04, 50.04]]
  ]
}

Two rings: a square, and a smaller square inside it that is not part of the area. Both close, in the sense that their first and last point are identical. The outer ring is the boundary; the inner one is the lake.

The direction a ring is walked in, clockwise or counter-clockwise, is called its winding, and it turns out to matter to some software: it is how a renderer tells a filled area from a hole cut out of one, without needing any other clue. PostGIS is forgiving about it; a good few other tools are not, and quietly draw the wrong thing rather than raising an error. Chapter 10 comes back to winding as one of the traps this series collects. Nothing in this chapter depends on getting it right.

The Multi- forms

Real outlines are not always one ring. A region can have an island, or an exclave separated from its own mainland by another region entirely, Wallonia does not, but plenty of real administrative areas do. A single Polygon cannot describe two disconnected pieces of ground, so GIS adds a second family of shapes: MultiPoint, MultiLineString, MultiPolygon, each one simply "more than one of the plain version, held together as a single value".

This project already stores real examples. Region.php, web/src/Catalog/Entity/Region.php, says as much directly in its own doc comment:

/** GeoJSON MultiPolygon (SRID 4326) via the "geometry" DBAL type. */
#[ORM\Column(type: 'geometry', nullable: true)]
private ?string $geom = null;

Every row in region holds a MultiPolygon, single ring or not. For several of the German Bundesländer that is the shape's own form: Schleswig-Holstein includes North Sea and Baltic islands, so its outline genuinely is several rings held as one value. For Wallonia it is a MultiPolygon holding one ring. One shape kind for the whole table means no reader ever has to branch on Polygon versus MultiPolygon.

None of this needed a new database column. Look back at the three declarations from the sections above, region, item, recommended_route, and every one of them says geometry(Geometry, 4326), never geometry(Point, 4326) or geometry(Polygon, 4326). Geometry is the permissive option: it means "any of the shapes above, plain or Multi-", decided per row by whatever the application actually writes into it, not pinned once at the table level. GeometryType::getSQLDeclaration() in web/src/Catalog/Doctrine/GeometryType.php is what generates that exact string, and it is the same string in every migration for this reason: it is one method's output, not six independent choices.

GeoJSON: the wire format

Every shape above is an idea. GeoJSON is the text format that idea travels in, the JSON-based way of writing a shape down so it can cross a network, sit in a request body, or be logged to a screen. Chapter 1 mentioned it in passing; this is where it earns the promise.

The three shapes this project stores, each on its real table and columnThree stacked panels, one per shape. The top panel is labelled item.geom: a small map fragment made of two crossing lines for streets, with one filled circle marking the fountain, captioned "Point, every catalog item". The middle panel is labelled recommended_route.geom: a winding curved line with small dots marking a couple of its points and an arrowhead at the far end, captioned "LineString, an ordered ride", the arrow showing which end is the start and which is the end. The bottom panel is labelled region.geom: a closed, filled, irregular outline with its first and last point marked as the same dot, captioned "MultiPolygon, one or more closed areas".item.geomPoint, every catalog itemthe fountainrecommended_route.geomLineString, an ordered ridestartendregion.geomMultiPolygon, one or more closed areasfirst = last
The same three shapes as above, each next to the real table and column that stores it. A Point is one filled dot; a LineString is an ordered line with a direction, shown here by the arrowhead at its end; a Polygon is a closed, filled outline whose first point and last point are the same one, and a region row holds one or more of them as a MultiPolygon. Every one of these three columns is declared geometry(Geometry, 4326), permissive about which shape it holds, not pinned to just one.

Written as GeoJSON, the fountain is a Point geometry:

{
  "type": "Point",
  "coordinates": [5.8983, 50.4851]
}

Look at the order inside coordinates again: [5.8983, 50.4851] is [longitude, latitude], not [latitude, longitude]. This is the same trap chapter 1 spent a whole section on, and GeoJSON is one of the two places that section named it happening: the specification defines coordinates as longitude first, always, with no exception and no configuration flag to change it.

A LineString's GeoJSON looks the same shape, just with a list of pairs instead of one: {"type": "LineString", "coordinates": [[5.879, 50.489], [5.881, 50.491], …]}. A Polygon nests one level deeper again, coordinates becomes a list of rings, each ring a list of point pairs, first pair repeated last: {"type": "Polygon", "coordinates": [[[5.87, 50.48], [5.89, 50.48], [5.89, 50.50], [5.87, 50.50], [5.87, 50.48]]]}. Same three shapes, same nesting-by-one-level pattern all the way up.

Crossing the boundary

PostGIS does not store geometry as GeoJSON internally. It keeps its own compact binary representation, built for fast comparison and indexing, not for being read by a human or handed straight to a browser. The application, on the other side, never wants to see that binary form, every geometry that reaches PHP is a GeoJSON string, and every geometry PHP hands back to the database is a GeoJSON string too. Something has to sit exactly on that boundary and translate in both directions, on every single read and every single write, without anybody having to remember to call it.

That something is GeometryType, web/src/Catalog/Doctrine/GeometryType.php, the same class chapter 1 introduced for its getSQLDeclaration() method. Its other two methods are the translation itself:

  • GeometryType::convertToDatabaseValueSQL() wraps every value written into a geometry column in ST_SetSRID(ST_GeomFromGeoJSON(…), 4326), parse the GeoJSON string into a PostGIS geometry, then stamp it as SRID 4326.
  • GeometryType::convertToPHPValueSQL() wraps every value read back out in ST_AsGeoJSON(…), take whatever PostGIS geometry is stored and render it back to a GeoJSON string.

Written out, the two methods are a mirror image of each other:

public function convertToDatabaseValueSQL(string $sqlExpr, AbstractPlatform $platform): string
{
    return sprintf('ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326)', $sqlExpr);
}

public function convertToPHPValueSQL(string $sqlExpr, AbstractPlatform $platform): string
{
    return sprintf('ST_AsGeoJSON(%s)', $sqlExpr);
}
The GeoJSON ⇄ PostGIS round tripA box on the left labelled PHP, GeoJSON, and a box on the right labelled PostGIS, geometry(Geometry, 4326). An arrow above the boxes runs left to right, from the PHP box to the PostGIS box, labelled ST_SetSRID wrapped around ST_GeomFromGeoJSON of the value, comma 4326, the write direction. A second arrow below the boxes runs right to left, from the PostGIS box back to the PHP box, labelled ST_AsGeoJSON of the value, the read direction. Only the write-direction arrow's label mentions an SRID; the read-direction arrow's does not.ST_SetSRID(ST_GeomFromGeoJSON(…), 4326)PHP / GeoJSONPostGISgeometry(Geometry, 4326)ST_AsGeoJSON(…)
PHP only ever holds a GeoJSON string; PostGIS only ever holds a geometry(Geometry, 4326) value. GeometryType sits on the boundary and translates both ways on every read and write. Notice which arrow carries the SRID: only the write, top arrow does, ST_SetSRID is there because GeoJSON has nowhere of its own to carry one, so it has to be re-asserted every single time a value crosses into PostGIS. The read arrow needs no such step, because the value is already labelled 4326 the moment it left the database.

Notice that the SRID only appears going one way. GeoJSON, as a text format, has no field for "which coordinate system are these numbers in", chapter 1's 50.4851, 5.8983 is meaningless without an SRID attached, and a bare GeoJSON string is exactly that: numbers with no SRID of their own. So on the way in, ST_SetSRID has to state it explicitly, every time, because there is nothing in the input to state it for us. On the way out there is nothing to re-assert: the geometry sitting in the database already carries 4326, ST_AsGeoJSON only has to print the numbers, and the resulting string is quietly missing an SRID again the moment it lands in PHP, until the next write sends it back through ST_SetSRID once more. The boundary is crossed constantly, and GeometryType is what keeps it from ever mattering to the rest of the codebase.

What to carry into chapter 3

  • This project uses exactly three shapes (Point, LineString, Polygon) plus their Multi- forms, and every geometry column is declared permissively enough to hold any of them.
  • A LineString's point order is meaningful: reverse it and you have described a different ride.
  • A Polygon's ring closes (first point = last point) and can have holes; its winding direction can matter to other tools even though PostGIS does not enforce it.
  • GeoJSON is longitude-first, the same rule chapter 1 already warned about, and it carries no SRID of its own, which is exactly why GeometryType re-asserts 4326 on every single write.

The fountain has a position, a coordinate system, and now a shape. What is still missing is a question: how far away is it? Degrees are angles, not distances, and the next chapter is about asking PostGIS for a real one.

Further reading

Try it

Hands-on: round-trip a real geometry through GeoJSON

Take one catalog item's stored geometry apart into the same GeoJSON text GeometryType produces on every read, then put it back together the way GeometryType does on every write, and watch where the SRID has to be re-stated. This uses the Côte de la Redoute pin from chapter 1's own example, selected by source_ref, the seed's own stable key: row ids are assigned per install and yours will not match the ones on the machine these outputs were captured on, and name stops being unique once a harvest adds an OpenStreetMap or Wikidata row for the same place.

SELECT ST_AsGeoJSON(geom) AS geojson, ST_SRID(geom) AS srid_in_db
FROM item WHERE source_ref = 'manual:cote-de-la-redoute';

                   geojson                       | srid_in_db
---------------------------------------------------+------------
 {"type":"Point","coordinates":[5.69924,50.49222]} |       4326
(1 row)

Look at the GeoJSON text on its own: a type, a pair of numbers, nothing that says which coordinate system they belong to. Now chain it back through the write side of the boundary, ST_GeomFromGeoJSON to parse it, ST_SetSRID(…, 4326) to re-assert the system, exactly the two calls GeometryType::convertToDatabaseValueSQL() wraps around every value this project writes:

WITH original AS (SELECT geom, ST_AsGeoJSON(geom) AS gj FROM item WHERE source_ref = 'manual:cote-de-la-redoute'),
     round_tripped AS (SELECT ST_SetSRID(ST_GeomFromGeoJSON(gj), 4326) AS geom2 FROM original)
SELECT ST_SRID(o.geom) AS srid_before, ST_SRID(r.geom2) AS srid_after,
       ST_Equals(o.geom, r.geom2) AS same_geometry
FROM original o, round_tripped r;

 srid_before | srid_after | same_geometry
-------------+------------+---------------
        4326 |       4326 | t

The point survives the round trip unchanged, same_geometry is t, and srid_after matches srid_before. But that match only holds because ST_SetSRID(…, 4326) said so in the query text; nothing in the GeoJSON string itself carried a system. Try the same parse without it, ST_SRID(ST_GeomFromGeoJSON(gj)) alone, and on the PostGIS 3.6 running in this dev stack it still happens to come back 4326, because that function's own default already assumes WGS84 when no crs is present, a behaviour of PostGIS 3.6 rather than a guarantee of the format. That default is exactly why the missing label is easy to forget about, and exactly why GeometryType states it outright in the code, rather than trusting a function default to hold across every PostGIS version this project will ever run on.