stillat/proteus
Proteus parses, updates, and writes Laravel-style PHP configuration files. Its ConfigWriter facade simplifies changing nested or multiple values, previewing output, guarding entries, and optionally rewriting function calls.
What it actually is. Proteus is a file-level parser/writer for Laravel-style PHP config files. It uses nikic/php-parser to read a config/*.php file into an AST, apply your mutations, and pretty-print it back to disk — trying to keep the surrounding formatting intact. It is not a runtime config store: it does not touch the config() repository in memory, and it does not read .env.
Install and orient.
composer require stillat/proteus
Package discovery should register the service provider; if you have discovery disabled, register the provider and add the Stillat\Proteus\Support\Facades\ConfigWriter alias manually. If you only ever use it from an install command or an admin UI that runs in production, keep it as a normal (non-dev) dependency.
First use case: the install/settings screen. The canonical job is "flip a value in a config file without hand-editing PHP" — set app.locale, append a provider, write a package config with an env() fallback.
Always start in preview mode in php artisan tinker:
use Stillat\Proteus\Support\Facades\ConfigWriter;
// See the exact output before touching disk:
echo ConfigWriter::preview('app.locale', 'fr');
// Then commit it:
ConfigWriter::write('app.locale', 'fr');
Where to look first. The README's "Intermediate" and "Advanced" sections are the real API surface. The advanced ConfigUpdater is the escape hatch when the facade's namespace-to-config/*.php resolution doesn't fit (e.g. a file outside config/):
$updater = new \Stillat\Proteus\ConfigUpdater();
$updater->open(base_path('stubs/config.php'));
$updater->update(['key' => 'new-key']);
$contents = $updater->getDocument();
Read the package's own tests once — the formatting/merge edge cases live there, not in the docs.
First thing to check before adopting. Confirm your Laravel version against the package constraint (Laravel 11/12/13 support landed across v3–v4.2.1; current is v4.4.1). Then verify your deployment target can actually write to config/: read-only container filesystems, Laravel Vapor/serverless, and ephemeral containers will silently lose the write on the next deploy. Decide now whether that's acceptable.
Preview → diff → write. Treat preview()/previewMany() as your dry-run mode. In a settings controller, render or log the preview, validate the resulting file (php -l or include it in a sandbox), and only then call save()/write(). This single habit avoids most of the "my config file exploded" reports.
Batch with writeMany, not a loop. One parse/print cycle per namespace:
ConfigWriter::writeMany('app', [
'locale' => 'fr',
'timezone' => 'Europe/Paris',
]);
Use edit() for multi-step, order-sensitive changes. Actions execute in chain order, and you get one write at the end:
ConfigWriter::edit('app')
->set(['locale' => 'fr', 'fallback_locale' => 'fr'])
->merge('providers', [SomeProvider::class])
->remove('legacy_key')
->save();
merge() de-duplicates; replace() overrides wholesale (use it when ordering of an array matters); remove() deletes the key from the file, not just its value.
Dedicated config file per feature area. Don't accumulate writes into config/app.php. Create config/features.php (skeleton via File::put if it doesn't exist) and write keys there, then read them with config('features.x'). This keeps diffs small, makes guards easy (features.*), and keeps your blast radius contained.
Guard + preserve as a safety belt around any user-driven write.
try {
ConfigWriter::guard('app.key')
->preserve(['app.url'])
->write('app.locale', $request->string('locale'));
} catch (\Stillat\Proteus\Exceptions\GuardedConfigurationMutationException $e) {
// reject the request
}
preserve() accepts dot notation and arrays of keys. Guards support namespace (app.*) and prefix (app.providers*) patterns.
Keep generated values portable with f(). Instead of baking absolute paths into config, emit the function call:
ConfigWriter::write('custom.path', ConfigWriter::f()->basePath('relative'));
// => 'path' => base_path('relative'),
Use makeSimpleFunctionCall('my_func', [$a, $b]) (public since v4.2.0) for anything globally available. Closures can be written since v3.
Wrap writes in a service class with a lock and a backup. Proteus has no transactional semantics — concurrent requests will clobber each other:
Cache::lock("config-write:{$file}", 10)->block(5, function () use ($file) {
File::copy($file, storage_path('config-backups/'.basename($file).'.'.now()->timestamp));
ConfigWriter::edit('app')->set([...])->save();
});
Log the preview string alongside the lock for an audit trail.
Refresh the in-process and cached config after writing. The file changed; config() did not:
ConfigWriter::write('app.locale', 'fr');
config(['app.locale' => 'fr']); // current request
Artisan::call('config:clear'); // if you use config:cache
In CI/CD or a deployment hook, the correct end state is usually "write the file, then run config:cache" so the compiled cache matches.
Test with preview() strings. You don't need a filesystem to assert behaviour:
$out = ConfigWriter::previewMany('app', ['locale' => 'fr']);
$this->assertStringContainsString("'locale' => 'fr'", $out);
For end-to-end coverage, point the package at a temp config/ directory (Testbench) and snapshot the whole file — formatting regressions are the most common breakage on upgrades.
Installer/scaffolding use case. merge('providers', [MyServiceProvider::class]) is the tidy way to register a package provider from a my-package:install command; set() a package config with an env() default in one pass.
Cached config shadows your write. With bootstrap/cache/config.php present, editing config/app.php changes nothing at runtime. Symptom: "the write succeeded but the value didn't change." Fix: clear/cache after writing, and verify with config() in the same request only if you also update it in memory.
env() calls are ignored by default — deliberately. Writing app.key against 'key' => env('APP_KEY') does not replace the call; it appends your value as the default second argument: env('APP_KEY', 'new-key'). If you truly want to overwrite the call, opt in explicitly:
ConfigWriter::ignoreFunctionCalls(false)->write(...);
Know that this is a global behaviour switch on that instance; apply it only to the operation that needs it.
Formatting is "mostly" preserved. The pretty-printer normalizes whitespace, may relocate comments, and will reformat arrays (trailing commas, indentation). Expect noisy diffs the first time you touch a hand-formatted file. Review preview() output before wiring it into anything that produces PRs.
Guards are checked during mutation and throw, not return false. GuardedConfigurationMutationException is the failure mode — wrap in try/catch and return a 422 rather than letting a settings form 500. Also verify your wildcard pattern does what you think: app.providers* is a prefix match, not a general glob.
remove() is destructive. It deletes the key from the file. If you want "unset but present", use set('key', null) — null values emit correctly (fixed in v1.0.2). Never remove() keys the framework or your app reads unconditionally.
writeMany is "smart" about merging since v2. Nested arrays may be merged into existing ones rather than replacing them. If you need an exact array replacement, use edit()->replace() and don't rely on writeMany semantics.
merge() de-duplicates — verify the comparison. Pass strings or ::class constants; mixing object instances and strings can defeat the dedupe or throw.
The facade is shared state. guard(), preserve(), and ignoreFunctionCalls() set state on the resolved instance. In a request or test that performs several writes, options from an earlier call can leak into a later one. Set the options explicitly per operation, or resolve a fresh writer/ConfigUpdater for isolated work.
Files must exist — but the file map now self-heals (v4.4.1). The low-level ConfigUpdater::open() still expects a real path, and for a brand-new config file you should still write a <?php return []; skeleton yourself before editing it. What changed in v4.4.1: when a namespace lookup misses, the package rescans the config directory instead of failing or returning stale state. Practically that means a file you create mid-request (e.g. File::put(config_path('features.php'), "<?php return [];")) is picked up by the already-resolved writer without you having to re-resolve the facade or restart the command — which matters for install commands that scaffold a config file and then immediately write into it in the same process. Don't over-read it: this is discovery-on-miss, not a cache invalidation contract. If you have long-lived workers (Octane, queue daemons) still holding a writer across a deploy that adds new config files, resolve fresh per operation. Also check the directory is writable — the failure is an exception from the filesystem layer, not from Proteus.
Validate before you persist. Run php -l against the preview (write to a temp file) or include it in a throwaway scope. A malformed config file takes the whole app down, and it's the one file you can't easily fix from inside the app.
Don't build key paths from raw user input. write($key, ...) accepts dot-notation paths; a user-supplied key could target anything in the config namespace. Allowlist the keys you accept.
Concurrency. No locking is built in. Two simultaneous admin saves = last-write-wins with a corrupted-feeling diff. Use Cache::lock() around the whole read-modify-write.
Maturity and risk. 18 stars, a single primary maintainer, and 0 dependents listed — treat it as a small, high-leverage but thinly-trafficked dependency. Pin the version in composer.json, keep snapshot tests over your generated files, and note that php-parser major bumps (v5 landed in v4.0.1) are the most likely source of formatting/behaviour churn. Releases are frequent and small (v4.2.1 → v4.4.1 in the span covered here, with no itemised notes for 4.3/4.4), so read the changelog diff before bumping rather than assuming patch-level safety on file-writing behaviour. MIT licence means forking is realistic if maintenance stalls; the AST-writing logic is the only part you'd need to preserve.
Debugging recipe. Dump preview() → save to /tmp/x.php → php -l /tmp/x.php → php -r "var_dump(include '/tmp/x.php');". That isolates parser problems, printer problems, and semantic problems in three quick steps. If a write targets the wrong file or a newly scaffolded file, log the namespace you passed and confirm the file exists on disk at the time of the call — v4.4.1's rescan-on-miss covers the common case, but the resolved path is still worth printing once when wiring things up.
How can I help you explore Laravel packages today?