Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Proteus Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style