DEV Community

Cover image for When “Just Calling a Function” Isn’t That Simple Anymore
Yonel Ceruto
Yonel Ceruto

Posted on

When “Just Calling a Function” Isn’t That Simple Anymore

At some point in almost every project, something small starts to feel heavier than it should.

You begin with a clean controller that delegates to a handler, then you add validation, then logging. Maybe a transaction. Then consistent error mapping. Nothing dramatic, just normal application growth.

A few months later, calling a handler isn’t just “calling a handler” anymore.

Some controllers validate first, others rely on the handler. Some catch domain exceptions and map them to HTTP responses. Others let them bubble up. Background workers behave slightly differently. CLI commands do their own thing.

The business logic is fine. The execution semantics aren’t.

And that’s when it becomes clear: invoking a callable is not just a method call. It’s an execution boundary.

That realization led to CallableInvoker.

Invokable objects have become common in modern PHP, from single-action controllers to DDD-style handlers using __invoke.

After using them extensively, I noticed something subtle: they simplify composition, but they also expose inconsistencies in how we execute application logic.

A War Story: The Job That Behaved Differently

In a previous project, we had an invokable service that generated invoices. It was used both by an HTTP endpoint and by a nightly background job.

A few weeks after release, finance noticed that invoices generated overnight didn’t match the ones created manually in the admin panel. The differences were small, mostly rounding, but real.

After tracing it, we found the cause: the HTTP flow always had an authenticated user, which carried tenant and currency configuration. The scheduled job didn’t. It fell back to defaults.

The service wasn’t wrong.

The way we prepared and executed it was inconsistent depending on where it was called from.

That’s when it became clear the problem wasn’t the business logic, it was the lack of a single, predictable way to invoke it.

A Better Way to Execute Callables

In PHP, calling a callable is trivial:

($handler)($command);
Enter fullscreen mode Exit fullscreen mode

But real applications are not trivial.

What if:

  • the callable has multiple parameters with defaults?
  • some arguments should come from a runtime contextual data?
  • you want consistent logging or transactions around execution?
  • retries or metrics should apply everywhere?

CallableInvoker reframes the problem.

Instead of asking, “How do I call this?”, it asks:

How should this be executed, and under what context?

It uses reflection to inspect the callable’s signature, resolves arguments from a provided context, applies defaults, and executes the callable through a pipeline.

That pipeline can include decorators, small wrappers that add behavior around execution.

Not inside your business logic. Around it.

From Boilerplate to Focused Handlers

Consider a simple handler:

class HelloHandler
{
    public function __invoke(string $name, int $age = 23): string
    {
        return "Hello, $name! You are $age years old.";
    }
}

$handler = new HelloHandler();
Enter fullscreen mode Exit fullscreen mode

Instead of manually extracting arguments and orchestrating execution:

use OpenSolid\CallableInvoker\CallableInvoker;

$invoker = new CallableInvoker();

$result = $invoker->invoke(
    callable: $handler,
    context: ['name' => 'Evelyn']
);

echo $result; // Output: Hello, Evelyn! You are 23 years old.
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • The invoker inspects the signature.
  • It resolves parameters from context.
  • It applies defaults.
  • It executes the callable through registered decorators (if any).

The handler stays focused on business rules. Everything else becomes execution policy.

Decorations: Infrastructure Without Pollution

Decorators are where this becomes useful.

A decorator can:

  • run logic before execution,
  • wrap the callable in a transaction,
  • measure execution time,
  • translate exceptions,
  • or modify the result.

Instead of scattering logging, or transaction boundaries across controllers and workers, you define them once.

Now every invocation follows the same rules. And that consistency is what we were missing in that earlier project.

In practice, decorators and parameter value resolvers don’t have to be global. They can be grouped and prioritized for specific artifact types. For example, HTTP controllers might have a different decorator stack than message handlers or CLI commands.

You can define execution profiles where certain behaviors apply only to a subset of callables, and control their order explicitly.

That means you’re not just centralizing execution, you’re shaping it per context.

Symfony Integration (Optional, But Powerful)

Although CallableInvoker is framework-agnostic, it integrates naturally with Symfony.

Symfony already resolves controller arguments, supports invokable console commands, and wires message handlers through Messenger. CallableInvoker can plug into that ecosystem by being registered as a service and configured with autowired decorators and value resolvers.

In practice, this means:

  • You can inject CallableInvoker anywhere in your application.
  • Decorators and resolvers can be tagged and auto-registered.
  • The Symfony container can participate in argument resolution.
  • Different execution profiles can be configured per environment or entry point.

This allows you to extend Symfony’s argument resolution and execution wrapping beyond controllers and commands, bringing the same behavior into your application layer.

It’s Not Magic (And It’s Not Free)

A note on tradeoffs: this approach adds indirection.

  • Reflection has overhead (usually negligible, but real).
  • There’s a mental model to understand.
  • Debugging execution flow requires knowing the decorator chain.

This isn’t something I’d use in a throwaway script or a small CRUD app.

But in long-lived systems, especially those mixing HTTP, CLI, and asynchronous handlers, having a single execution abstraction pays off quickly.

Why This Matters in Real Systems

As applications evolve, divergence happens quietly.

When callables don’t share a strict interface, when __invoke signatures vary, arguments aren’t known at compile time, or inputs depend on the entry point, invocation becomes a dynamic resolution problem, not just a method call.

CallableInvoker centralizes how callables are prepared and executed. Even when their shape varies, their behavior stays consistent.

And that consistency is architectural leverage.

A Small Tool That Changes the Shape of Your Code

The biggest improvements in a system often come from refining repeated patterns.

Calling a callable is one of those patterns.

Turning that from “just a function call” into a controlled execution boundary doesn’t add features. It adds discipline.

In our case, it eliminated duplicated glue code, aligned error handling across entry points, and made new handlers simpler to write.

It didn’t make the system flashy. It made it consistent.

And for long-lived applications, consistency is what scales.


Take a tiny detour to 👉 https://github.com/open-solid/callable-invoker

If it makes your inner developer smile even a little... tap that ⭐ and make the repo’s day! 😄

Top comments (0)