State vs. Chaos: Why Your Local Dev Isn't Just Running Commands
State vs. Chaos: Why Your Local Dev Isn't Just Running Commands
You open your terminal. docker-compose up. You wait - a minute, maybe two. You hit an endpoint. Connection refused. You've been here before. So you restart everything. It works. You don't know why, and you move on.
That isn't a mystery. It's a state problem.
A local development environment is a small distributed system. Laravel, PHP, Vite, Node, a queue worker, Redis, Postgres, and Docker each have their own lifecycle. A green process means only that something is running. It does not mean the system is ready, healthy, or doing useful work.
Running is not ready
The distinction is easy to miss because terminal output compresses several different states into one reassuring word: started. A container can be running while the service inside is still initializing. A database can accept a TCP connection while a schema is missing. A Vite process can bind to a port while the browser is still receiving stale assets. A queue worker can be alive while it cannot reach Redis or has no jobs to process.
Docker's documentation makes this explicit: startup order and healthchecks are different tools. Compose can control when commands launch, but that does not establish when dependencies are actually ready. A healthcheck paired with a dependency condition such as service_healthy tells the environment what actually matters.
We've found it useful to separate five states:
Started: the process or container exists.
Ready: the service can perform its expected work.
Healthy: its checks are passing over time.
Degraded: it is present but missing a dependency or capability.
Stopped: it is no longer available.
These are not labels for a dashboard. They are different debugging paths.
Model the environment as contracts
Before adding more automation, write down what each service actually promises. For Laravel, that means an HTTP request returns the expected response, not just that the server process exists. For Vite, it means the dev server serves the expected asset, not just that the Node process is alive. For Postgres, it means connections succeed and the expected database is available. For Redis, it means it accepts a ping and the application can authenticate. For a queue worker, it means the worker can reach the backend and has processed or acknowledged work recently.
The exact checks depend on the project. The important part is that "ready" describes a useful behavior, not a process-table fact. Docker Compose can express container relationships. Laravel Sail provides a command-line interface. Laravel's Vite integration documents how the frontend development server fits into the application workflow. None of these tools can decide what healthy means for your particular project without a project-level contract.
Startup order is a weak guarantee
A common stack flows like this: database feeds the application, which spawns a queue worker and Vite on the side. It is tempting to start them in that sequence and assume everything works. But sequence only describes when commands are launched. It does not describe when their dependencies can actually serve requests.
A stronger contract says: start the database, then wait until the database passes its readiness check. Start the application, then verify that it can connect to the database and load its configuration. Start the worker, then verify that it can reach its queue backend and emit a fresh heartbeat. In Compose, that often means pairing a healthcheck with a dependency condition. The configuration is not magic. It simply moves an assumption out of a developer's head and into a test the environment can report.
Failure becomes local instead of global. Instead of seeing "the app is broken," you can see "the database is running but not ready" or "the worker is ready but degraded because Redis is unavailable." That is a much shorter route to the fix.
Process state needs an ending too
Most local tooling is good at starting processes. The messy part is stopping them. A crashed terminal, a closed laptop lid, a failed hot reload, or a force-quit can leave behind a process that still owns a port or continues consuming resources. The next session starts with yesterday's state, but the terminal looks clean enough to encourage a wrong diagnosis.
Services need a predictable response to termination: stop accepting new work, finish or release what is safe to finish, close connections, and exit. Workers need a similar policy so they do not disappear halfway through a job or silently lose their heartbeat. For local development, the practical rule is simple: every long-running process should have an observable owner and a known shutdown path.
We can record this as simply as:
process: queue-worker
pid: 48192
port: none
state: degraded
https://userig.app/blog/state-vs-chaos-why-your-local-dev-isn-t-just-running-commands