nette/neon
NEON is a human-friendly, YAML-like data serialization format used for Nette configuration files. This package provides a fast, strict parser and dumper for PHP, with support for comments, inheritance, entities and includes, plus detailed error reporting.
Install with Composer — there is no Laravel service provider to publish:
composer require nette/neon
The entire public surface is in Nette\Neon\Neon:
use Nette\Neon\Neon;
$data = Neon::decode("
mail:
driver: smtp
host: mail.example.com
port: 587
");
// ['mail' => ['driver' => 'smtp', 'host' => 'mail.example.com', 'port' => 587]]
echo Neon::encode($data, blockMode: true);
Where to look first, in order:
Nette\Neon\Neon — three methods only: decode(), decodeFile(), encode(). That is your 90 % API.Nette\Neon\Exception — every parse failure throws this, with the offending line. Always catch it at the boundary.Nette\Neon\Linter — validates a string and throws on syntax errors, useful for CI.Nette\Neon\Node\* and Nette\Neon\Parser — the AST layer, only relevant if you need position info or custom traversal.First use case in a Laravel app: a feature-flag / tooling config that humans edit. Do not start by ripping out config/*.php — start by reading a single config/features.neon at boot and merging it into the container:
// app/Providers/NeonConfigServiceProvider.php
public function register(): void
{
$path = config_path('features.neon');
if (! is_file($path)) {
return;
}
config(['features' => Neon::decodeFile($path)]);
}
decodeFile() is atomic (reads the whole file before parsing), so a half-written deploy artifact will throw rather than yield a truncated array.
Wrap decoding in one place so you control caching, error surface, and file resolution:
namespace App\Support;
use Nette\Neon\Neon;
use Nette\Neon\Exception as NeonException;
final class NeonConfig
{
/** @return array<string, mixed> */
public static function load(string $path): array
{
try {
$data = Neon::decodeFile($path);
} catch (NeonException $e) {
throw new \RuntimeException("Invalid NEON config: {$path} — {$e->getMessage()}", 0, $e);
}
return is_array($data) ? $data : [];
}
}
Then merge in a provider registered before anything that reads those keys:
public function register(): void
{
$base = NeonConfig::load(config_path('services.neon'));
$env = NeonConfig::load(config_path('services.'.app()->environment().'.neon'));
config(['services_ext' => array_replace_recursive($base, $env)]);
}
This mirrors Laravel's mergeConfigFrom() semantics but gives you NEON's readable block syntax for deeply nested structures instead of PHP arrays.
NEON's nested block arrays are the selling point. Keep one file per environment rather than scattering env() calls throughout the tree:
# config/services.neon
queue:
driver: redis
connection: default
retry_after: 90
# config/services.production.neon
queue:
connection: priority
retry_after: 120
Deep-merge the environment file on top of the base file at boot. This keeps the production delta reviewable in a pull request.
Syntax errors in a hand-edited NEON file must never reach runtime. Add an artisan command that lints every .neon file in the repo:
use Nette\Neon\Linter;
use Symfony\Component\Finder\Finder;
public function handle(): int
{
$failed = 0;
foreach (Finder::create()->files()->in(base_path())->name('*.neon') as $file) {
try {
Linter::lint($file->getContents());
} catch (\Nette\Neon\Exception $e) {
$this->error("{$file->getRelativePathname()}: {$e->getMessage()}");
$failed++;
}
}
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
Wire it into your CI pipeline next to phpstan and pint. Note that linting only proves the file parses — it will not catch semantic drift such as a date-shaped value quietly becoming a string (see Gotchas), so add a separate assertion pass over the decoded array when those keys matter.
Neon::encode($data) is most useful for generating fixtures, scaffolding new config files, or producing diffs that humans review:
file_put_contents(
base_path('stubs/config.neon'),
Neon::encode($defaults, blockMode: true)
);
blockMode: true forces the nested-block format; the default is inline flow style, which is compact but unreadable for anything hierarchical. The third parameter controls indentation (default is a tab).
The decoder's return value is plain PHP, but Parser gives you an AST with $startLine / $endLine on every node. That is how you build a custom "this config key was deprecated, see line 14" diagnostics pass:
use Nette\Neon\Parser;
use Nette\Neon\Node;
use Nette\Neon\Traverser;
$ast = (new Parser)->parse($neonString);
(new Traverser(function (Node $node) {
if ($node instanceof Node\StringNode && $node->value === 'legacy_queue') {
throw new \RuntimeException("Deprecated value on line {$node->startLine}");
}
}))->traverse($ast);
Use DontTraverseChildren / StopTraversal from Nette\Neon to exit early instead of walking the whole tree.
Because typed scalars (dates, entities, big integers) can appear in the result tree, funnel every decoded array through a normaliser before it reaches application code:
final class NeonNormalizer
{
public static function normalize(mixed $value): mixed
{
if ($value instanceof \Nette\Neon\Entity) {
return ['__entity' => $value->value, 'args' => $value->attributes];
}
if ($value instanceof \DateTimeInterface) {
return $value->format(\DateTimeInterface::ATOM);
}
if (is_array($value)) {
return array_map([self::class, 'normalize'], $value);
}
return $value;
}
}
One place to look when a config value "is not what I typed" saves hours of dd() archaeology.
indentation: ' ' to Neon::encode() consistently and never mix.decode() does not strip a UTF-8 BOM. Since v3.3.1 BOM removal was dropped. If your editor writes one, strip it yourself (ltrim($s, "\u{FEFF}")) or use decodeFile(). A BOM causes a confusing "Unexpected character" on line 1.encode() to validate data.PHP_INT_MAX decodes to a string, which is safe but surprising if you type-hint int. Cast explicitly at the boundary and expect TypeError otherwise.DateTimeImmutable, not DateTime (since v2.4.0). Values that look like timestamps silently become objects — including strings in your config that happen to match the pattern. Quote them ('2024-01-01') if you want to keep them as strings. Keys are not date-decoded, only values.2024-02-30 and 2024-13-01 used to be a parse failure; they now come back as the plain strings '2024-02-30' / '2024-13-01'. This is the dangerous combination with the previous point: a date-shaped value is now either a DateTimeImmutable or a string, and you cannot tell from the source text alone. If your code assumes "date-shaped ⇒ object", a typo'd date in a config file will no longer blow up at boot — it will surface later as a TypeError or, worse, be persisted as a string. Validate date keys explicitly in your loader (instanceof DateTimeInterface check, or quote nothing and assert the type), and add a test for the typo case.!!chain is a reserved entity name since v3.4.9. It is now used internally for chained entities and can no longer appear in input — a file containing !!chain will be rejected at parse time. If you generate NEON from templates or user input, add a guard so a user-supplied key/value never produces that token; Linter::lint() in CI is the cheapest place to catch it. The flip side: chained-entity syntax is now expressible natively, so Parser output can contain entity nodes you did not write by hand.INF and NAN throw on encode (v3.4.1). If you ever pass computed floats into encode(), guard with is_finite() first.on / off were removed in v3.4.0 as boolean literals. Only true, false, null, yes, no remain. Old Nette configs copied into your repo will break with a syntax error.foo(bar)) returns Nette\Neon\Entity objects, not arrays. If a config value looks like Factory(...), your decoded array contains an object. Either avoid the syntax in user-facing config or handle Entity instances explicitly when normalizing.^3.4.9 before you bump the Docker base image, otherwise you can end up with a PHP 8.6 runtime and a NEON version that has never been tested against it.config:cache is your real risk. Laravel's config cache serializes the config() array — anything you merged from a NEON file is frozen at cache time. The NEON file is then not re-read until the cache is cleared. Either accept that (the normal Laravel contract) or read the NEON file lazily outside the config repository and cache it yourself with Cache::remember() keyed on filemtime().Nette\Neon\Exception message alone ("Unexpected ':' on line 7") is useless when you have fifteen .neon files.NOASSERTION on Packagist. Verify the actual license terms with your legal/compliance process before shipping it in a distributed product; for internal tooling it is a non-issue.NeonConfig) rather than sprinkling Neon::decode() calls across providers, so a future swap to YAML or JSON is a one-file change.!!chain reservation, PHP 8.6 support) landed inside the 3.4 line. A ^3.4 constraint means a routine composer update can change how an existing config file decodes without any code change on your side — lock the version and treat NEON upgrades as a reviewed change with the linter run against your real config tree.Parser → Traverser → your own visitor. The AST is stable since v3.3.0, and Traverser can substitute nodes, which is how you implement !include style directives yourself by expanding StringNode values during traversal.How can I help you explore Laravel packages today?