Parse KML and KMZ files in a Laravel application. Read placemarks, styles and document metadata as plain arrays, or convert the whole thing to GeoJSON.
PHP 8.3 or later, Laravel 12 or 13, and the simplexml, libxml and zip extensions.
Laravel 11 is supported by the 2.x line. It reached the end of its security window in March 2026 and the advisories open against it have no fix in the 11.x branch, so 3.x does not accept it.
composer require plin-code/kml-parserPublish the config file if you need to change anything:
php artisan vendor:publish --tag="kml-parser-config"use PlinCode\KmlParser\KmlParser;
$parser = new KmlParser();
$parser->loadFromFile('path/to/file.kml');
$placemarks = $parser->getPlacemarks();
$styles = $parser->getStyles();
$styleMaps = $parser->getStyleMaps();
$name = $parser->getDocumentName();
$description = $parser->getDocumentDescription();
$geoJson = $parser->toGeoJson();A KMZ archive works the same way, loadFromKmz() picks the KML out of the archive for you:
$parser->loadFromKmz('path/to/file.kmz');You can also load from a string with loadFromString().
KML is a large format and this package covers a subset of it. What that subset is:
| Element | Supported | Notes |
|---|---|---|
Document |
yes | required, name and description are read |
Placemark |
yes | name, description, styleUrl, geometry |
Point |
yes | |
LineString |
yes | |
Polygon |
yes | outer boundary plus any number of inner boundaries |
MultiGeometry |
yes | nests, and maps to a GeoJSON GeometryCollection |
Style |
yes | only styles carrying an id, see below |
StyleMap |
yes | normal and highlight pairs |
IconStyle |
yes | scale, Icon/href, hotSpot |
LabelStyle |
yes | scale, color |
LineStyle |
yes | color, width |
PolyStyle |
yes | color, fill, outline |
ExtendedData |
yes | both Data pairs and SchemaData/SimpleData entries |
Folder |
yes | the list stays flat, each placemark carries the path of folders containing it |
NetworkLink |
no | |
GroundOverlay, ScreenOverlay, PhotoOverlay |
no | |
TimeStamp, TimeSpan |
no | |
LookAt, Camera, Region |
no | |
gx:Track and the rest of the gx: extensions |
no |
A Style declared inline on a Placemark has no id and cannot be referenced by a styleUrl, so getStyles() skips it. Only shared styles appear in the returned map.
Anything in the "no" column is ignored rather than rejected. A document using those elements still parses, you just do not get that data back.
A list, one entry per Placemark, in document order. name, description and folder are always present, styleUrl and extendedData only when the Placemark declares them.
A Point carries a single position:
[
'name' => 'Lago Blu',
'description' => 'A lake',
'folder' => ['Piemonte', 'Laghi'],
'type' => 'Point',
'coordinates' => [
'longitude' => 7.7,
'latitude' => 45.8,
'altitude' => 10.0,
],
'styleUrl' => '#pair',
'extendedData' => ['area' => '12'],
]folder is the names of the Folder elements containing the Placemark, outermost first, and an empty array for a Placemark sitting directly under the Document. The list itself stays flat, so grouping is yours to do:
collect($parser->getPlacemarks())
->groupBy(fn (array $placemark) => implode('/', $placemark['folder']));A Folder without a name contributes an empty string rather than being skipped, so the length of the path always matches the real nesting depth.
A LineString carries a list of them:
[
'name' => 'Route',
'description' => '',
'folder' => [],
'type' => 'LineString',
'coordinates' => [
['longitude' => 7.1, 'latitude' => 45.1, 'altitude' => 0.0],
['longitude' => 7.2, 'latitude' => 45.2, 'altitude' => 0.0],
],
]A Polygon splits its rings:
[
'name' => 'Area',
'description' => '',
'folder' => [],
'type' => 'Polygon',
'coordinates' => [
'outerBoundary' => [
['longitude' => 7.0, 'latitude' => 45.0, 'altitude' => 0.0],
// ...
],
'innerBoundaries' => [
[
['longitude' => 7.1, 'latitude' => 45.1, 'altitude' => 0.0],
// ...
],
],
],
]innerBoundaries is an empty array when the polygon has no holes.
A MultiGeometry has no coordinates of its own. It carries geometries instead, each entry shaped exactly like a standalone geometry of that type, and it can nest:
[
'name' => 'Mixed',
'description' => '',
'folder' => [],
'type' => 'MultiGeometry',
'geometries' => [
['type' => 'Point', 'coordinates' => [...]],
['type' => 'LineString', 'coordinates' => [...]],
],
]So switch on type before reaching for coordinates.
Keyed by style id. Every sub-style is optional, and inside each one only the elements the document actually declares are reported:
[
'pin' => [
'id' => 'pin',
'iconStyle' => [
'scale' => 1.2,
'href' => 'images/icon.png',
'hotSpot' => ['x' => 32.0, 'y' => 64.0, 'xunits' => 'pixels', 'yunits' => 'insetPixels'],
],
'labelStyle' => ['scale' => 0.8, 'color' => 'ff112233'],
'lineStyle' => ['color' => 'ff0000ff', 'width' => 4.0],
'polyStyle' => ['color' => '7f00ff00', 'fill' => false, 'outline' => true],
],
]KML defines defaults for all of these (width 1, fill and outline 1, and so on). The parser does not fill them in, so an absent key means the file said nothing about it, not that the value is the default. Apply your own defaults if you need them.
color values are returned as the raw KML aabbggrr hex string, not converted to rrggbb. Note the byte order, KML puts alpha first and blue before red.
Keyed by StyleMap id:
[
'pair' => [
'id' => 'pair',
'pairs' => [
'normal' => '#pin',
'highlight' => '#pin',
],
],
]The name and description of the first Document element, or null when absent.
A FeatureCollection. Positions come out as [longitude, latitude, altitude], which is the GeoJSON order, and altitude is always present, 0 when the file omits it. folder, styleUrl and extendedData are carried into properties, each only when the Placemark has one.
[
'type' => 'FeatureCollection',
'features' => [
[
'type' => 'Feature',
'properties' => [
'name' => 'Lago Blu',
'description' => 'A lake',
'styleUrl' => '#pair',
'extendedData' => ['area' => '12'],
],
'geometry' => [
'type' => 'Point',
'coordinates' => [7.7, 45.8, 10.0],
],
],
],
]A MultiGeometry becomes a GeometryCollection.
The result is a PHP array, so encode it yourself when you need the wire format:
return response()->json($parser->toGeoJson());KML predates the OGC, and exporters still emit the older Google namespaces. All four of these are accepted out of the box:
http://www.opengis.net/kml/2.2
http://earth.google.com/kml/2.2
http://earth.google.com/kml/2.1
http://earth.google.com/kml/2.0
A document declaring anything else is rejected with Invalid or missing KML namespace. Add your own through the supported_namespaces config key. XPath always runs against whichever namespace the document actually declares, so a 2.1 file is queried as 2.1.
A KMZ is a ZIP archive holding a KML file and, usually, the icons it references. loadFromKmz() reads the first KML entry it finds and ignores the rest.
To get at the other files, for example to serve the icons:
use PlinCode\KmlParser\KmzExtractor;
$extractor = new KmzExtractor();
$files = $extractor->extractAllFiles('path/to/file.kmz', 'extraction/directory');extractAllFiles() returns the list of entry names it wrote. Leave the destination out and it writes to a directory of its own under temp_directory, or under the system temp directory when that is null:
$files = $extractor->extractAllFiles('path/to/file.kmz');Archives are checked before anything is read out of them. An archive is rejected when it declares more than max_archive_entries entries, when its entries add up to more than max_uncompressed_size bytes uncompressed, or when any entry name is absolute or contains .. and would therefore write outside the destination. Set either limit to 0 to turn it off.
Everything the package throws extends PlinCode\KmlParser\Exceptions\KmlException, so one catch is enough to cover it:
use PlinCode\KmlParser\Exceptions\KmlException;
try {
$placemarks = (new KmlParser())->loadFromFile($path)->getPlacemarks();
} catch (KmlException $e) {
report($e);
}Catch the subclasses when you need to tell the cases apart:
| Exception | Thrown when |
|---|---|
KmlException |
the content is not valid KML: wrong namespace, no Document, a Placemark without geometry, coordinates out of range |
KmlParserException |
the file is missing, the XML does not parse, or a getter is called before anything was loaded |
KmzExtractorException |
the KMZ is missing, is not a readable ZIP, or holds no KML entry |
Coordinate validation runs at load time, so loadFromString() rejects a longitude outside -180 to 180 or a latitude outside -90 to 90 before you ever see a placemark.
use PlinCode\KmlParser\Facades\KmlParser;
$placemarks = KmlParser::loadFromFile('path/to/file.kml')->getPlacemarks();The facade resolves a container binding that is scoped to the request or queue job, so the loaded document does not carry over between requests under Octane or inside a long running worker. Keep the calls chained, or hold on to the instance, rather than assuming a later KmlParser::getPlacemarks() still sees what an earlier call loaded.
return [
// The primary namespace, also used as a fallback.
'namespace' => 'http://www.opengis.net/kml/2.2',
// Namespaces a document is allowed to declare.
'supported_namespaces' => [
'http://www.opengis.net/kml/2.2',
'http://earth.google.com/kml/2.2',
'http://earth.google.com/kml/2.1',
'http://earth.google.com/kml/2.0',
],
// Where extractAllFiles() writes when given no destination.
// null means the system temp directory.
'temp_directory' => null,
// Ceilings applied to a KMZ before anything is read out of it.
// Set either to 0 to disable it.
'max_archive_entries' => 5000,
'max_uncompressed_size' => 256 * 1024 * 1024,
];composer test
composer analyse
composer formatPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.
