Every abstraction in a software project is a bet. A bet that the developed interface will survive the requirements of the future, not just those of today. Anyone who has ever developed a framework, an SDK, or even just an internal library knows the scenario: The first version works flawlessly, but after just a few months, development teams are fighting against their own architecture instead of with it. Source code gets copied, internals get overridden, and the desire for a "workaround" becomes louder than the documentation.

The difference between abstractions that carry long-term and those that collapse under real requirements rarely lies in the technical sophistication of the initial implementation. It lies in a fundamental design decision: Do we give users methods to fill in, or do we let them describe what they want?

This paradigm shift from instruction-based to declarative API design is one of the most effective levers for scalable software development and sustainable developer experience. In this article, we show why declarative abstractions are superior, how progressive complexity layers work, where the limits lie, and why this principle remains relevant even in the age of AI-assisted development.

Instructions vs. Descriptions: The Crucial Difference

Most abstractions in software development follow a familiar pattern: A framework provides hooks, lifecycle methods, or abstract classes that developers fill with their own logic. The framework takes care of the mechanical parts (file parsing, loops, error handling), and the developer writes their code into the designated gaps.

That sounds like a clean division of labor. But it's an illusion.

The Problem with Imperative Hooks

Let's imagine a CSV import system. A base class processes files, iterates over rows, and catches errors. Developers override a processRow method with their individual logic: remove currency symbols, normalize values, cast types.

What happens in practice?

  • Inconsistency: Every developer implements Boolean parsing differently. One handles "yes" and "true" but forgets "1". The next catches exceptions during casting, a third doesn't.
  • Invisible Logic: The framework can't see what happens inside the overridden method. It can neither validate, nor catch errors, nor optimize.
  • No Cumulative Improvements: If a bugfix for Boolean parsing becomes necessary, every single importer must be manually updated.

This is the weakness of instruction-based abstraction: The developer doesn't describe what they want, but writes how it should work. The framework handles the loop, but the actual code remains a black box.

The Declarative Approach

What is the actual intention behind the code in processRow? In most cases, the entire logic can be reduced to a simple statement: I have three columns. "Name" is text, "Price" is a number, "Active" is a Boolean.

That's exactly the idea behind declarative API design: Developers describe their columns, and the framework takes care of everything else. Boolean formats, number formats, validation, error handling, progress tracking. All of this is solved centrally and consistently.

A declarative approach offers decisive advantages over the imperative:

  • Visibility: The framework knows the structure. It knows which columns exist and what type they have.
  • Consistency: Booleans are handled the same everywhere, a bugfix affects all importers.
  • Cumulative Improvements: Every new feature the framework adds automatically benefits every existing description.

This principle is by no means new. Laravel's validation rules work exactly like this: required|email|unique. Nobody writes SQL to check uniqueness. You describe constraints and the framework handles the implementation. The related concept of "Progressive Disclosure of Complexity" has established itself as one of the most effective design principles for APIs and frameworks.

Progressive Complexity Layers: Start Simple, Go Deeper When Needed

A common objection to declarative abstractions is: "What if my use case is too complex?" The concern is justified. Not every user needs the same level of control. Some want to flip a switch and move on, others need to fine-tune behavior, and a few power users want complete control.

Most abstractions choose one level and force everyone there. Either the interface is too simple for advanced scenarios, or too complex for simple cases. Declarative design solves this dilemma through layers.

Three Levels of Control

Level 1: The Flag The simplest case: a single method call like numeric() that sets a Boolean. Most users stop here. This one call handles null values, empty strings, currency symbols, thousands separators, all invisible to the developer.

Level 2: Options The same method, but with optional parameters. numeric(decimalPlaces: 2) makes the description more specific. Those who need this precision provide it. Those who don't continue using the parameterless call.

Level 3: The Escape Hatch This is where it becomes crucial. A closure-based property gives the developer full control: They take over processing completely. The framework steps aside.

This three-tier model (flag, then options, then closure) is the practical implementation of Progressive Disclosure. Each level gives more control when needed, without burdening simple cases.

Why Escape Hatches Are Indispensable

Without an escape hatch, the only way out for developers is to leave the abstraction. That means: copying source code, building workarounds, in the worst case replacing the entire tool. A deliberately built-in escape hatch is not an admission of weakness, but an acknowledgment that no description can cover every conceivable use case.

And yes, within an escape hatch, the framework loses visibility into the logic. But that only affects that one spot. All other described components still fully benefit from the framework logic.

Closures as First-Class Citizens: Timing and Context

For escape hatches to actually be used, closures must feel as natural as the configuration they replace. Two problems must be solved for this: when closures are executed and what they receive.

The Timing Problem: Deferred Evaluation

A common scenario: A configuration property should depend on the current user at runtime, such as the maximum row count of an import based on the subscription. But if the configuration code runs before middleware, the user context doesn't exist yet.

The naive solution of evaluating the closure immediately when setting doesn't solve the problem. What works: Store the closure as a closure and only evaluate it when reading. This way it executes at the latest possible moment, when the request context is fully built.

// Closure is stored, not executed
$importer->maxRows(fn () => auth()->user()->plan->maxImportRows);

// Only evaluated when accessed
public function getMaxRows(): int
{
    return value($this->maxRows);
}

This is Deferred Description: The closure runs when the value is needed, not when it's set. If it's never needed, it never runs.

The Context Problem: Reflection-Based Injection

The second problem concerns the parameters that closures receive. The classic approach looks like this: Every closure gets four, five or more parameters passed (state, row data, record, options). Most closures need at most one or two of these, but the signature must still be fully served.

Worse still: Every new context parameter gets appended to the end of the list. The order becomes arbitrary, and reordering breaks all existing closures.

The more elegant solution: Closures declare what they need. Through PHP Reflection, the framework reads the closure's parameter names and types and injects only the requested values. Those who need $data get the row data. Those who need $record get the Eloquent model. Unknown parameters are resolved from the service container, exactly how Laravel's controller injection works.

The result: Closures are lean, readable, and extensible without breaking existing implementations.

Translating Errors: When Descriptions Fail

This is where it becomes clear whether an abstraction is truly well thought out. The entire philosophy of declarative design is based on hiding complexity. But that's exactly what becomes a problem when something goes wrong.

A developer describes three columns, a relationship, a Boolean. The description looks clean. But a typo in the relationship name leads to an exception deep in a trait in the vendor directory, 50 stack frames away from the actual error.

At this moment, it's decided whether developers trust the abstraction or look for alternatives.

From Implementation Language to Description Language

The solution: Errors must be communicated in the language of the description, not in the language of the implementation.

  • Bad: BadMethodCallException in ForwardsCalls.php line 42
  • Good: Relationship [categry] does not exist on model [Product]. Did you mean [category]?

The first message speaks the language of the framework. The second speaks the language of the developer: It references columns, relationship names and models, exactly the terms the developer wrote themselves.

Good error handling in abstractions follows the principle of linguistic symmetry: On the way in, descriptions are translated into internals. On the way out, exceptions are translated back into description language. Practically, this means: catching framework exceptions and re-throwing them with context-rich, description-close error messages.

Recognizing the Boundary: When Description Ends and Code Begins

This is perhaps the most important aspect in designing declarative abstractions. Knowing where description ends is just as crucial as knowing how to apply it.

The Litmus Test

There's a practical test that reliably shows whether something should be described or not:

Can you list all valid options?

  • If the number of valid values is finite (yes/no, a handful of options, an enum), then it's a description. Make it configurable.
  • If the number of valid values is infinite (arbitrary PHP expressions, any conceivable database query, domain-specific logic), then it's not suitable for a description. Make it a method or closure.

Example: Upsert Logic

For CSV import, a decision must be made for each row: Is this a new record or an update? Which existing record is updated? How is it matched?

For some imports, the SKU is the unique key. For others, the email. For others still, a combination of date and location. Case sensitivity may or may not be relevant. A match can mean an update, a skip, or an error.

What happens when you try to describe this?

  1. You start with a single property: upsertKey = 'sku'. Clean, simple, covers most cases.
  2. Then someone needs two columns: upsertKeys = ['sku', 'region'].
  3. Then values need to be transformed before matching: upsertTransforms = [...].

At this point, you're rebuilding PHP with worse syntax. The configuration language is harder to use than the code it's supposed to replace.

In such cases, the right answer is: Just let them write PHP. Five lines of code, any conceivable query, and the framework deliberately steps back.

Mechanical vs. Meaningful

The dividing line between description and code follows a clear pattern:

Mechanical Work (→ abstract) Meaningful Work (→ leave as code)
Parse CSV files What makes a record unique?
Dispatch background jobs How is a conflict handled?
Track progress in UI Which business rules apply?
Format and log errors Which notification is urgent?
Cast types consistently Who gets notified?

Good abstractions don't hide everything. They hide the mechanical and expose the meaningful. This principle extends far beyond import systems: A notification system should abstract the delivery pipeline, but leave the decision of who gets notified and what the message contains as code.

Declarative Design and AI: Why the Principle Becomes More Relevant

An obvious objection: If AI tools like Copilot or Claude make writing methods practically free, does declarative design lose relevance? After all, an AI agent can generate any processRow method in seconds.

The answer is: The opposite is true.

An AI agent also has an "experience" with an abstraction. It encounters error messages, must understand context, and interact with the API. A declarative API is easier for AI agents to use than an instruction-based one, because descriptions are closer to natural language than imperative code.

Furthermore, the framework can inspect, validate, and optimize a description, whether it was written by a human or an AI. With an imperative method, the framework can only execute and hope.

Principles for Practice: Checklist for Better Abstractions

In summary, the declarative approach yields the following guiding principles for designing abstractions, whether in a framework, an internal library, or a Laravel-based application:

  1. Description instead of Instruction: Let users describe what they want, not how it works. Every description gives your framework visibility into the intention. Visibility accumulates over time.

  2. Progressive Complexity Layers: Flag → Options → Closure. Each level gives more control. Users choose their depth themselves.

  3. Build in Escape Hatches: Without an emergency exit, the only way out is to quit. Consciously plan places where developers can take full control.

  4. Implement Closures Thoughtfully: Deferred Evaluation (execution when reading, not when setting) and Reflection-based Context Injection make closures as natural as configuration.

  5. Translate Errors: On the way in: Description → Internals. On the way out: Exceptions → Description language. Error messages in the user's language, not the implementation's.

  6. Respect Boundaries: If valid options are infinite, it's not a case for description. Abstract mechanical work, leave meaningful work as code.

Conclusion: Abstractions That Survive Being Wrong

Every abstraction is a bet on how it will be used. This bet is always partially wrong, perhaps even at the point where you were most certain. The question isn't whether you're wrong, but whether the abstraction survives being wrong.

Declarative API design doesn't make abstractions infallible. But it makes them adaptable. Because the framework can see the description, it can do more and more with it over time without existing code needing to be changed. Because escape hatches exist, users don't have to leave the abstraction when an edge case occurs. And because errors are communicated in the language of the description, trust is maintained even when something goes wrong.

Those who build web applications and platforms that grow and change over years benefit from abstractions that support this growth. At mindtwo, we rely on architectural decisions that not only meet today's requirements but still provide room tomorrow. Declarative design is one of the building blocks for this, in our own projects and in the frameworks we use.

The next abstraction you build: Don't just ask whether the code works. Ask whether it survives when you were wrong.