DEV Community

Cover image for Lean Software Development in Practice: Finding Muda in Four PHP Projects
Alkin Veysal
Alkin Veysal

Posted on

Lean Software Development in Practice: Finding Muda in Four PHP Projects

Lean has one word that I like because it is brutally simple: Muda.

Waste.

In a factory, waste is often visible. Extra movement. Waiting. Too much inventory. Producing something before it is needed.

In software, it is much better at hiding.

An unused abstraction does not block a corridor. A feature nobody asked for does not sit on a pallet. A public API that became too large does not look like inventory.

But all of them still cost something.

They have to be designed, written, tested, documented and maintained. And once other developers start depending on them, removing them becomes much harder than adding them.

That made me look at four of my own open-source PHP projects from a slightly different angle.

Three are Symfony bundles:

The fourth is a standalone CLI tool:

I have already written separate technical articles about all four projects:

This time, I do not want to explain again how the packages work.

The more interesting question is:

What did I deliberately choose not to build?

That is where the Lean part becomes interesting.

Muda is not the same as "more code"

This was the first thing I had to be careful about.

Lean software development does not mean deleting code until everything is small.

Sometimes more code is exactly what prevents a real failure.

For example, in HttpIdempotencyBundle the idempotency record is read again after acquiring the lock.

That is extra work.

It is also necessary.

Another request may have completed the operation between the first read and the moment the lock was acquired. Skipping the second read would make the code shorter, but also wrong.

The same happens in OptimisticConcurrencyBundle.

The HTTP layer checks If-Match, but Doctrine still performs its own optimistic-lock check during flush().

Two checks.

Two different race windows.

Both useful.

So the question is not:

How can this be done with fewer lines?

A better question is:

Does this complexity protect something real, or does it exist only because it might be useful one day?

That difference matters.


OptimisticConcurrencyBundle: do not build concurrency twice

Repository: github.com/alkinbg/optimistic-concurrency-bundle

Earlier article: Preventing Lost Updates in Symfony APIs with ETags and Doctrine

The bundle solves a specific problem: preventing stale clients from silently overwriting newer data.

A resource can expose an ETag:

#[EntityTag('document', scope: 'document-detail-v1')]
public function show(Document $document): JsonResponse
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

and a write can require the client to send the same representation version back:

#[RequireIfMatch('document', scope: 'document-detail-v1')]
public function update(Document $document): JsonResponse
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

There is an obvious temptation here.

Once you start solving concurrency, it is easy to keep going.

The bundle could maintain its own entity versions.

It could introduce its own locking mechanism.

It could control transactions.

It could call flush().

It could become "the concurrency layer".

But Doctrine already has optimistic locking through #[ORM\Version].

So the bundle does not replace it.

The HTTP layer answers:

Is the representation used by this client stale?

Doctrine answers:

Did the entity change before the database write completed?

That division is useful.

Building a second persistence-level concurrency system would not remove the need for Doctrine's protection. It would mostly add another implementation to understand and another set of failure cases to maintain.

That is a form of Muda I see quite often in software:

rebuilding something that another layer already does well.

There is another small detail in this project that I now appreciate more.

The supported public API is intentionally small.

Most implementation classes are internal, and there is an architecture test that protects that boundary.

Why care?

Because every public class is a promise.

If someone starts depending on it, changing it later becomes a backward-compatibility problem.

A large public API can easily become a kind of inventory: things you now have to keep carrying even when you no longer want them.

Sometimes the Lean choice is not removing code.

Sometimes it is simply not exposing it.


MaskedBundle: do not try to detect every secret in the universe

Repository: github.com/alkinbg/masked-bundle

Earlier article: I Made a Symfony Bundle for Masking Sensitive Data

MaskedBundle started from a practical problem.

Sensitive data can end up in logs.

Once you start thinking about automatic detection, the feature list grows very quickly.

Passwords.

API tokens.

JWTs.

Session IDs.

Private keys.

Database credentials.

Cloud access keys.

Payment data.

It is very easy to imagine a "smart" detector for all of them.

It is also very easy to create a large pile of heuristics.

The bundle takes a narrower approach.

Automatic detection is conservative and currently focuses on payment-card candidates where there is enough structure to make a reasonably confident decision.

When the application already knows a value is sensitive, it can pass it explicitly:

$masked = $sensitiveDataMasker->mask(
    'Authentication failed for token '.$token,
    sensitiveValues: [$token],
);
Enter fullscreen mode Exit fullscreen mode

That design looks simple, but the reason behind it matters.

If the application already knows that $token is sensitive, the masking library does not need another complicated heuristic to rediscover that fact.

Trying to detect everything automatically would mean more regexes, more edge cases and, probably, more false confidence.

There is also another important choice in the implementation: detection work is bounded.

If a safety budget is exhausted, masking fails closed instead of continuing an expensive scan forever.

That is not "less code".

It is extra defensive code with a clear purpose.

The Muda would be adding twenty more speculative detectors just because the architecture allows it.

This is one of the traps I have started to notice more often:

A good abstraction makes new features easy.

And when new features are easy, it becomes very tempting to add them.

But "easy to add" and "worth adding" are not the same thing.


Doctrine Migration Guard: "I don't know" can be a valid result

Repository: github.com/alkinbg/doctrine-migration-guard

Earlier article: Catching Risky Doctrine Migrations Before Production

Doctrine Migration Guard is probably the clearest example of scope control in these four projects.

Its job is simple to describe:

Check Doctrine migration files for risky MySQL and MariaDB operations before they reach production.

The dangerous part is everything that could be added around that idea.

The tool could connect to the database.

It could inspect table size.

It could understand server versions.

It could discover pending migrations.

It could support PostgreSQL.

It could have a plugin system.

It could read Git history.

It could rewrite dangerous SQL.

Every one of those ideas sounds reasonable.

That does not mean they belong in version 0.1.

The first release intentionally understands a narrow migration shape.

This is straightforward:

$this->addSql(
    'ALTER TABLE users ADD nickname VARCHAR(64) DEFAULT NULL'
);
Enter fullscreen mode Exit fullscreen mode

This is not:

$sql = 'ALTER TABLE users ADD nickname VARCHAR(64)';
$this->addSql($sql);
Enter fullscreen mode Exit fullscreen mode

The tool does not try to guess what dynamic PHP code will do.

It reports the result as incomplete.

The same happens with SQL constructs that the current analyzer cannot classify safely.

They become UNANALYZED.

At first glance, this looks like weakness.

I think it is the opposite.

A static analyzer that understands only part of a construct and still returns green is more dangerous than one that says:

I don't know.

This is where Lean and safety meet quite nicely.

Supporting every possible migration shape would require much more parsing, much more testing and many more edge cases.

If the actual value of the first release is catching common risky migrations in CI, then building everything else before that idea proves useful is simply overproduction.

Software people often call that future-proofing.

Sometimes it really is.

Sometimes it is just work arriving too early.


HttpIdempotencyBundle: do not promise exactly-once when you cannot deliver it

Repository: github.com/alkinbg/http-idempotency-bundle

Earlier article: When HTTP Retries Become Dangerous: Idempotency in Symfony Without the Fairy Tales

Idempotency is another area where it is very easy to over-promise.

The bundle protects selected controller actions from accidental duplicate execution when clients retry HTTP requests.

Protection is explicit:

#[Idempotent]
public function createOrder(): JsonResponse
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

It could have been automatic.

For example:

"Every POST, PUT and PATCH should be idempotent."

That sounds convenient.

It is also a huge assumption.

Not every endpoint has the same semantics. Automatically changing every write request would make the bundle responsible for application behavior it cannot fully understand.

So the controller has to opt in.

More importantly, the bundle does not claim exactly-once execution.

Because it cannot guarantee it.

Imagine this:

A controller calls an external payment provider.

The payment succeeds.

Before the completed idempotency record is saved, the PHP process crashes.

The side effect has already happened.

No Symfony event subscriber can travel back in time and undo that payment.

Adding more internal machinery does not change this fundamental failure window.

So the bundle handles the part it can actually control:

request identity, fingerprints, shared state, locking and response replay.

Other guarantees remain where they belong:

database constraints, transactions, provider-side idempotency, outbox patterns and domain-specific protection.

For me, this is another form of Lean thinking:

do not spend complexity trying to create a guarantee your layer cannot honestly provide.

More code would not make the promise true.

It would only make the implementation bigger.


The interesting part was not what the projects contain

After looking at the four repositories this way, something surprised me.

I expected the best examples of Muda to be dead code, duplicated methods or unnecessary classes.

Those things matter.

But the more interesting waste appears earlier.

It appears when deciding what the software should become.

In OptimisticConcurrencyBundle, the important decision was not to replace Doctrine.

In MaskedBundle, it was not to pretend every type of secret can be detected automatically.

In Doctrine Migration Guard, it was not to support every migration shape in the first release.

In HttpIdempotencyBundle, it was not to enable idempotency everywhere and not to claim exactly-once execution.

None of those decisions look impressive in an architecture diagram.

That may be exactly why they are easy to miss.

Sometimes avoiding waste does not result in a clever class.

Sometimes the result is that the class never gets written.


A small checklist before adding "just one more thing"

These are the questions I now try to ask before adding another feature, abstraction or extension point:

  1. Is there a real use case now, or only a possible future one?
  2. Does another layer already solve this problem?
  3. Am I adding an abstraction before I have a second real case for it?
  4. Am I making the public API larger than necessary?
  5. If I cannot know something safely, is "unknown" better than a guess?
  6. Will this feature create enough value to justify its tests, documentation and future compatibility cost?

I do not always get this right.

Building things is enjoyable. That is part of the problem.

A generic solution feels elegant.

A new extension point feels flexible.

Another supported scenario makes the README look stronger.

But every feature creates work after it is merged.

That part is much less exciting, and much easier to forget.


One final thought

Lean and software development are not a perfect translation of each other.

Code is not a production line, and forcing every programming problem into one of the classic Lean wastes would not be very useful.

But the basic idea behind Muda translates extremely well:

Effort is not the same as value.

A complicated solution can be necessary.

A simple solution can be wrong.

The goal is not minimal code.

The goal is to spend complexity where it protects something real.

That changed one question for me.

Instead of always asking:

What else should this support?

there is another question that is often more useful:

What happens if this is not built?

Sometimes the answer is:

Nothing.

And sometimes that is the best possible result.

Top comments (1)