winter/laravel-config-writer
A Laravel package for writing and updating configuration files at runtime. It lets you modify config values programmatically and save them back to disk, making dynamic settings changes easier without manual file edits.
winter/laravel-config-writer is a small, dependency-free utility for the one job Laravel doesn't give you: mutating config files and .env from code. It ships two writer classes — one for PHP config arrays, one for dotenv files — and nothing else. There is no runtime magic, no facade you need to register for most use, and no database.
Install
composer require winter/laravel-config-writer:^1.2.2
Pin at ^1.2.2 or higher: v1.2.2 added support for doubles, so earlier versions mangle decimal values on write (more on this in Gotchas).
Where to look first
Open src/ before you write a line of integration code. The whole package is a handful of files; you want to read ConfigWriter and EnvWriter and confirm the exact method names and return types for the version you pulled (the API has shifted slightly across releases — some methods return the rendered file contents as a string, others write to disk directly, and which is which matters a lot for your error handling). Budget ten minutes here; it will save you an afternoon.
First use case: a first-run installer command
The canonical use is an artisan install / artisan app:configure command that asks for DB credentials and writes them out:
use Winter\LaravelConfigWriter\ConfigWriter;
use Winter\LaravelConfigWriter\EnvWriter;
// 1. Render the new .env (merge with what's already there)
$env = EnvWriter::write('.env', [
'APP_NAME' => '"My SaaS"',
'APP_ENV' => 'production',
'APP_KEY' => 'base64:' . base64_encode(random_bytes(32)),
'DB_CONNECTION'=> 'mysql',
'DB_HOST' => '127.0.0.1',
'DB_DATABASE' => $database,
'DB_USERNAME' => $username,
'DB_PASSWORD' => $password,
]);
// 2. Optionally push non-secret tweaks into config/*
ConfigWriter::write('config/database.php', [
'connections.mysql.strict' => true,
'connections.mysql.timeout' => 5.5, // floats land correctly on >= 1.2.2
]);
// 3. The step everyone forgets
Artisan::call('config:clear');
That's the whole loop: render → write → clear caches. Everything below is elaboration on it.
The dominant pattern. Collect input via $this->ask() / $this->secret(), validate, then write .env first and config/ second (env is the source of truth; config files should mostly stay env()-driven).
protected function handle(): int
{
$this->backup('.env');
try {
EnvWriter::write(base_path('.env'), $this->credentials());
ConfigWriter::write(base_path('config/database.php'), [
'connections.mysql.database' => $this->argument('database'),
]);
} catch (\Throwable $e) {
$this->restore('.env');
$this->error($e->getMessage());
return self::FAILURE;
}
Artisan::call('config:clear');
$this->info('Written. Run `php artisan migrate` next.');
return self::SUCCESS;
}
Always write the file to a temp path and rename() it into place if you're doing your own disk I/O on top of the package — an interrupted write to .env will take the application down hard, and .env is the one file you cannot recover from git.
When you install a sub-application (a module, a tenant scaffold, a marketplace plugin) you often need to register a service provider or add a disk:
$app = include config_path('app.php'); // read current state
$app['providers'][] = MyModule\ServiceProvider::class;
ConfigWriter::write(config_path('app.php'), $app);
Read → mutate → write is safer than blind-writing a partial array, because the writer's merge semantics for nested keys are the thing most likely to bite you. If you're only changing a single scalar, prefer the dotted-key form the package supports; if you're pushing to a list, read the file, array_push, and write the whole thing back.
Writing config inside a queued job (one tenant, one directory) is legitimate. Two rules:
config/ directory from a web request — race conditions, permission issues, and cache invalidation storms.config(['tenant' => require $path]).public function handle(): void
{
$path = storage_path("tenants/{$this->tenant->id}/config.php");
ConfigWriter::write($path, [
'mail.from.address' => $this->tenant->support_email,
'services.stripe.key' => $this->tenant->stripe_key,
'billing.tax_rate' => 0.0825, // fine on v1.2.2+
'queue.retry_after' => 90,
]);
// PHP config files are opcached — invalidate explicitly
if (function_exists('opcache_invalidate')) {
opcache_invalidate($path, true);
}
}
Floats are common in tenant config (tax rates, sampling percentages, rate-limit refill rates), which makes the v1.2.2 fix more relevant here than in a typical installer.
Because the package is thin, the useful move is to put a ConfigManager of your own in front of it: backups, dry-run, audit logging, permission checks, and a single flush() that clears config + opcache. Then your commands and jobs depend on your class, not on the vendor API — which insulates you from the method-name churn mentioned above.
final class ConfigManager
{
public function set(string $file, array $values, bool $dryRun = false): string
{
$rendered = ConfigWriter::write(base_path($file), $values); // returns contents
$this->guardWritable(base_path($file));
if (! $dryRun) { $this->atomicPut(base_path($file), $rendered); }
return $rendered;
}
}
The $dryRun flag is worth its weight: it lets you show a diff in the installer before committing, and lets you unit-test the rendering without touching the filesystem. It's also where you'd assert numeric types survive the round-trip.
env() indirection. Write secrets to .env and have config files reference env('KEY') rather than baking literals into config/*.php. It keeps config files commit-safe.laravel/prompts for the interactive parts; the writer has no UI and shouldn't.Storage::fake-style temp directory or a Testbench app_path(), assert on the rendered string, and never let a test touch the real .env. Snapshot-test the output — it catches escaping regressions. Add a case with a decimal value (1.5, 0.0) now that doubles are supported; that's the newest surface area.Floats were silently mangled before v1.2.2. v1.2.2 ("Added support for doubles") fixes rendering of decimal values. On older versions, writing 0.0825 or 1.5 could come back as an int, a truncated value, or a quoted string depending on the code path — a bug that is invisible in the diff if you only glance at it, and genuinely dangerous for things like tax rates, timeouts, and sampling ratios. If your writes include any non-integer number, require ^1.2.2 and add a round-trip test. Note that a float written as 1.0 may still render as 1 or 1.0 depending on the renderer; if the exact literal matters to a downstream parser, verify it once rather than assuming.
Numeric strings and numbers are different. '5.0' (string) and 5.0 (float) should render differently — one quoted, one not. Be explicit in your input array about which you mean, because env('X', 5.0) and env('X', '5.0') behave differently in Laravel.
Config cache will silently defeat you. Writing config/app.php changes nothing in a running app if bootstrap/cache/config.php exists. In production with config:cache in your deploy script, the app reads the cache, not your file. Always follow a write with config:clear (or config:cache if you're staying cached). This is the #1 source of "I wrote it but nothing happened" bug reports.
Comments and formatting are not preserved. The writer parses files as PHP arrays and re-renders them, so hand-written comments, custom indentation, and array ordering in your config files will be flattened. If a config file is precious (a curated config/app.php you maintain by hand), append or use a targeted Str::replace instead of a full array rewrite. Commit after running the command, not before — the diff is the review.
Env value escaping is on you. Dotenv is not PHP. Values containing spaces, #, $, or quotes need quoting; null and '' are different; booleans render as true/false and env('X', true) will coerce. Base64 APP_KEY values end in = and are fine, but passwords with # will truncate if unquoted. Test with an adversarial password before you ship the installer. Floats in .env are plain strings — APP_TAX_RATE=0.0825 — so no special handling, but don't expect type preservation on the way back through env().
include reads, not config(). If you read current state for a merge, include config_path('x.php') gives you the file's state; config('x') gives you the cached state. They can disagree. Decide deliberately which one you want, and prefer the file when writing.
Permissions and atomicity. www-data usually can't write config/, and on some hosts the file owner changes on upload. Check is_writable() early and fail loudly with a readable message, and use rename() (atomic on the same filesystem) rather than file_put_contents for anything safety-critical. Back up .env to .env.backup first — the package doesn't do this for you.
Opcache. PHP config files are cached; a write to config/ from a CLI command can be invisible to an already-running FPM pool. opcache_invalidate($path, true), or reload FPM as part of deploy.
Don't do this at request time. Writing config on a web request is a deploy-time concern masquerading as application logic. Restrict writes to console commands, installer middleware that runs once, or queue workers with a lock (Cache::lock). Concurrent writers to the same file is a data-loss bug waiting to happen.
It's a small single-maintainer utility package. That's not a red flag — it's an architecture signal. The package is deliberately a leaf dependency with no Laravel service provider complexity, MIT-licensed, and small enough to vendor or fork in an afternoon. Releases are infrequent and narrow (v1.2.2 is a one-line feature bump), so pin the version in composer.json and re-read the diff on upgrade — there's no meaningful test suite contract to lean on, and a type-handling change like float support is exactly the kind of thing that ships without fanfare.
Extension points are thin but real: subclass ConfigWriter/EnvWriter and bind your subclass in the container if you want dry-run, logging, or a different rendering strategy injected everywhere without touching call sites. That's the intended shape — wrap, don't patch.
How can I help you explore Laravel packages today?