plin-code/kml-parser
Parse KML and KMZ files in Laravel to extract placemarks, styles, style maps, and document metadata. Simple installation, configurable namespace and temp directory, with a clean API for reading geographic data.
Install and publish config
composer require plin-code/kml-parser
php artisan vendor:publish --tag="kml-parser-config"
Publish even if you change nothing. The README's config snippet is stale: it predates the KMZ archive ceilings added in 3.0, so the file you actually get contains keys the docs never show. Read the published config/kml-parser.php and treat it as authoritative.
Where to look first
config/kml-parser.php — namespace (leave at http://www.opengis.net/kml/2.2 unless a vendor ships a non-standard dialect), temp_directory for KMZ scratch extraction, and the archive limits.folder key on placemarks, the corrected unreadable-file error, and the PHPStan level 9 typing pass — all of which change how you consume return values.PlinCode\KmlParser\KmlValidator plus the exception tree (KmlException → KmlParserException / KmzExtractorException). That is your input contract.The whole public surface is small: loadFromFile(), loadFromKmz(), loadFromString(), then getPlacemarks(), getStyles(), getStyleMaps(), getDocumentName(), getDocumentDescription(), toGeoJson(). No models, no migrations, no stored state to manage.
First use case: ingest a user-uploaded map
use PlinCode\KmlParser\KmlParser;
use PlinCode\KmlParser\Exceptions\KmlParserException;
$parser = new KmlParser();
try {
str_ends_with(strtolower($path), '.kmz')
? $parser->loadFromKmz($path)
: $parser->loadFromFile($path);
} catch (KmlParserException $e) {
report($e);
return back()->withErrors(['file' => 'That map file could not be read.']);
}
foreach ($parser->getPlacemarks() as $placemark) {
ImportedPoint::create([
'name' => $placemark['name'] ?? null,
'layer' => implode('/', $placemark['folder']), // ['Piemonte', 'Laghi']
'type' => $placemark['type'] ?? null, // Point, LineString, MultiGeometry…
'coordinates'=> $placemark['coordinates'] ?? null,
]);
}
Everything downstream is identical for KMZ; only the loader call differs. The facade (KmlParser::loadFromFile(...)->getPlacemarks()) is convenient in tinker and one-liners, but prefer new KmlParser() in application code — see the gotchas.
One service, one door. Funnel the extension check, size guard, and try/catch through a single KmlImportService used by controllers, commands, and jobs. Return your own DTO rather than passing raw arrays around — the maintainer explicitly keeps placemarks as a heterogeneous map, so the shape is not enforceable by the type system. Normalise once at the boundary:
final class KmlImportService
{
public function import(string $absolutePath): ParsedDocument
{
$parser = new KmlParser();
str_ends_with(Str::lower($absolutePath), '.kmz')
? $parser->loadFromKmz($absolutePath)
: $parser->loadFromFile($absolutePath);
return new ParsedDocument(
name: $parser->getDocumentName(),
description: $parser->getDocumentDescription(),
placemarks: $parser->getPlacemarks(),
);
}
}
Parse off the request thread. The parser loads the document into a DOM and runs a //kml:Placemark XPath over it. There is no streaming path. A 20 MB KMZ is a request timeout and a trivial DoS if user-supplied. Queue it and enforce a byte ceiling at upload validation:
final class ImportKmlFile implements ShouldQueue
{
public int $timeout = 120;
public function __construct(public string $diskPath) {}
public function handle(KmlImportService $importer): void
{
$importer->import(Storage::path($this->diskPath));
}
}
Group by layer without extra queries. The folder path is returned outermost-first and the list stays flat, so grouping is a one-liner — ideal for rebuilding a Google My Maps export as nested UI:
$layers = collect($parser->getPlacemarks())
->groupBy(fn (array $p) => implode('/', $p['folder']));
Feed maps directly with GeoJSON. toGeoJson() is the shortest path to a Leaflet/Mapbox frontend; folder paths land in properties for foldered placemarks only.
return response()->json(json_decode($parser->toGeoJson()));
KMZ assets. If the archive carries icons or overlays you actually need, KmzExtractor::extractAllFiles($kmz, $dir) unpacks everything to a directory — then copy what you need into your own storage disk rather than serving from the temp dir.
Seed and test with fixtures. Keep real .kml/.kmz samples in tests/Fixtures/ — a flat document, a foldered one, a MultiGeometry, and a deliberately corrupt file for the exception path. Assert on individual keys ($placemark['name']), never on whole-array identity.
Isolate the dependency. Three stars and zero dependents means it can be abandoned. Put KmlImportService behind an interface so swapping in a fork or a different parser is a one-class change.
folder is new in 3.1.0. Placemarks gained the key, so a === or Pest toBe() comparison against a full array will fail. Reading individual keys is unaffected. It is an empty array for placemarks directly under Document, and contains an empty string for unnamed folders — path length always equals nesting depth.(int) 'plenty' === 0, which silently switched protection off). Don't rely on 0 meaning "no limit" unless the config comment says so for that key.Unable to read KML file: <path> means permissions, mid-write, or a broken symlink; a missing file has its own message. Both are worth surfacing differently to users.loadFromString(). Don't assume the file/KMZ loaders give you identical pre-validation — catch KmlParserException around every entry point regardless.KmlException → KmlParserException / KmzExtractorException. Catching the base class hides which stage failed; keep the KMZ extraction failure separate if you surface retry semantics.new KmlParser() per document, especially in queue workers and Octane, where a resolved facade instance can outlive one request.google Earth extensions (gx:Track, gx:coord), ExtendedData custom fields, and time spans are not part of the documented surface. If your source files come from Google Earth rather than My Maps, verify against a real fixture before committing to the package.getStyles()/getStyleMaps() give you the declarations and styleUrl references; there is no automatic join back onto placemarks. Build the lookup yourself if you need icon colours or line widths in your UI.coordinates is absent on MultiGeometry — that's precisely the key PHPStan level 9 could not guarantee. Always use ?? null / isset() on geometry keys.temp_directory defaults to the system temp path; extracted files linger. Extract into a per-import directory and delete it in a finally, or point the config at a directory your own cleanup command sweeps.How can I help you explore Laravel packages today?