---
title: "Backbone Console Project"
description: "Turn Any Backbone Operation Into a CLI Command"
type: "docs"
category: "doc"
tags: [php]
authors: [Anonymous]
date: "2026-10-21"
last_update: "2026-10-21"
time_minutes: 1
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/core/backbone-console"
---

# Turn Any Backbone Operation Into a CLI Command



---

## Introduction

Exposes every Backbone operation as an auto-discovered Symfony Console command, with no Command class per operation

# Turn Any Backbone Operation Into a CLI Command

[![GitHub](https://img.shields.io/badge/github-derafu%2Fbackbone--console-blue?logo=github)](https://github.com/derafu/backbone-console)
![GitHub last commit](https://img.shields.io/github/last-commit/derafu/backbone-console/main)
![CI Workflow](https://github.com/derafu/backbone-console/actions/workflows/ci.yml/badge.svg?branch=main&amp;event=push)
![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/derafu/backbone-console)
![GitHub Issues](https://img.shields.io/github/issues-raw/derafu/backbone-console)
![Total Downloads](https://poser.pugx.org/derafu/backbone-console/downloads)
![Monthly Downloads](https://poser.pugx.org/derafu/backbone-console/d/monthly)

Exposes every operation a [`SafeExplorerInterface`](https://www.derafu.dev/docs/core/backbone-dispatcher) discovers as its own auto-discovered [Symfony Console](https://symfony.com/doc/current/components/console.html) command — no per-operation `Command` subclass, ever.

## Why

[Backbone Dispatcher](https://www.derafu.dev/docs/core/backbone-dispatcher) already turns a string operation id and a plain array of parameters into a safe, serializable call. This package is one more transport on top of it — the same idea [Backbone API](https://www.derafu.dev/docs/core/backbone-api) is for HTTP, but for a standalone CLI process, meant to be run by hand or invoked from any other language via `exec()`/`system()`/`subprocess.run()`, not just from PHP.

A business library can expose hundreds of operations. `OperationCommandLoader` builds a `Symfony\Component\Console\Command\Command` for exactly one, on demand, only when a `bin/console` invocation actually needs it — never all of them upfront.

## Installation

```bash
composer require derafu/backbone-console
```

## Requirements

PHP 8.5+. Depends on [`derafu/backbone-dispatcher`](https://www.derafu.dev/docs/core/backbone-dispatcher), [`derafu/console`](https://www.derafu.dev/docs/core/console), `derafu/xml`, and `symfony/console`/`symfony/yaml`.




---

## Usage

Usage

# Usage

Compose a Kernel with [`derafu/console`](https://www.derafu.dev/docs/core/console)&#039;s `ConsoleKernelTrait`, and install `OperationCommandLoader` as the `CommandLoaderInterface`:

```php
use Derafu\BackboneConsole\Service\OperationCommandLoader;
use Derafu\BackboneDispatcher\Contract\SafeDispatcherInterface;
use Derafu\BackboneDispatcher\Contract\SafeExplorerInterface;
use Derafu\Console\ConsoleKernelTrait;
use Symfony\Component\Console\Application as SymfonyConsoleApplication;
use Symfony\Component\DependencyInjection\ContainerInterface;

class ConsoleApplication extends YourOwnKernel // e.g. your business library&#039;s own Kernel.
{
    use ConsoleKernelTrait {
        createConsoleApplication as private buildBaseConsoleApplication;
    }

    protected function createConsoleApplication(
        ContainerInterface $container
    ): SymfonyConsoleApplication {
        $application = $this-&gt;buildBaseConsoleApplication($container);

        $application-&gt;setCommandLoader(new OperationCommandLoader(
            $container-&gt;get(SafeExplorerInterface::class),
            $container-&gt;get(SafeDispatcherInterface::class),
        ));

        return $application;
    }
}
```

```php
// bin/console
use App\ConsoleApplication;
use Derafu\Console\Runtime;

require dirname(__DIR__) . &#039;/vendor/autoload.php&#039;;

exit(Runtime::run(fn (array $context): ConsoleApplication =&gt; new ConsoleApplication(
    $context[&#039;APP_ENV&#039;],
    (bool) $context[&#039;APP_DEBUG&#039;],
)));
```

Extending your own Kernel directly (rather than `Derafu\Console\Kernel`) is deliberate whenever the console needs the exact same `services.yaml`-wired services (like `SafeExplorerInterface`/`SafeDispatcherInterface` above) as the rest of the project — see [Console](https://www.derafu.dev/docs/core/console#a-project-that-already-has-its-own-kernel).

Every discovered operation becomes its own command, named by converting its id&#039;s `.`/`::` separators to Symfony Console&#039;s own `:` convention:

```
billing.invoice.builder::build  →  billing:invoice:builder:build
```

```bash
bin/console list
bin/console help billing:invoice:builder:build
```

## The Request: One File (or STDIN), Any Format

A `Worker`&#039;s operations can each take arbitrarily different parameters — there is no fixed set of CLI flags to define per command, only reflection (via `SafeExplorerInterface`) knows the shape at runtime. So the request is always a single JSON, YAML, or XML document, with the operation&#039;s parameters under a `&quot;parameters&quot;` key — the same shape an HTTP body would use:

```bash
echo &#039;{&quot;parameters&quot;: {&quot;number&quot;: &quot;F-001&quot;, &quot;amount&quot;: 15000}}&#039; \
    | bin/console billing:invoice:builder:build

bin/console billing:invoice:builder:build request.json
bin/console billing:invoice:builder:build request.yaml
bin/console billing:invoice:builder:build -   # &quot;-&quot;: explicit STDIN.
```

The `input` argument is optional — omitted, or given as `-`, reads from STDIN. Format is auto-detected from content: a leading `&lt;` is tried as XML, then JSON, then YAML (YAML is a syntactic superset of JSON, so trying it first would mean JSON is never actually detected as its own format).

Content starting with `{`/`[` is treated as an unambiguous signal that JSON was intended: if it fails to parse as JSON, that is a real, thrown error (`EX_DATAERR`, see below) rather than a silent fallback to YAML. Without this, some malformed JSON parses successfully as something else instead of failing loudly — a trailing comma (`{&quot;a&quot;: 5,}`) is invalid JSON but valid YAML flow-style, so it used to silently reinterpret into a document simply missing whatever came after the comma. The trade-off: a hand-written top-level YAML *flow-style* document (`{a: 5}` written as YAML on purpose, not as a JSON typo) is no longer accepted — YAML&#039;s more common block style (`a: 5` on its own line) is completely unaffected.

## The Response: Same Format as the Request, or Forced With `--output`

Without `--output`, the response is written to STDOUT in the same format the request came in as — a successful one is `{&quot;meta&quot;: {&quot;timestamp&quot;: ..., &quot;data_type&quot;: ...}, &quot;data&quot;: &lt;value&gt;}` (the same envelope [Backbone API](https://www.derafu.dev/docs/core/backbone-api) uses, so a caller does not have to special-case which transport it is talking to); a failed one is `ProblemDetailInterface::toArray()` (see [Backbone Dispatcher](https://www.derafu.dev/docs/core/backbone-dispatcher/results#handling-failure-problemdetail), `extensions.timestamp`/`extensions.data_type` included there too), written to STDERR instead, alongside a resolved exit code (see below).

`--output &lt;path&gt;` and `--error-output &lt;path&gt;` write to a real file instead of STDOUT/STDERR, independently of each other — `--output` only ever applies on success, `--error-output` only ever on failure (a single invocation only ever hits one of the two branches, so they can even point at the same path without conflict). Piping with shell redirection (`&gt;`/`2&gt;`) already works, but conflates the structured payload with anything else the process might write; these options guarantee only the payload lands in the file:

```bash
bin/console billing:invoice:builder:build request.json --output=result.yaml
bin/console billing:invoice:builder:build request.json --error-output=problem.xml
```

The destination&#039;s own file extension (`.json`/`.yaml`/`.yml`/`.xml`) decides the *response* format, independent of the *request*&#039;s — an unrecognized or missing extension (e.g. `.txt`) falls back to the request&#039;s format, same as omitting the option entirely. `-` is accepted explicitly for &quot;STDOUT&quot;/&quot;STDERR&quot;, the same convention the `input` argument already uses.

A write failure is never silent: a missing destination directory or a permissions error is reported on the real STDERR and returns `GenericOperationCommand::EX_CANTCREAT` — never a false &quot;success&quot; while nothing was actually written.




---

## Metadata and Exit Codes

Metadata and Exit Codes

# Metadata and Exit Codes

## Execution Metadata: `-v`

The response payload&#039;s *shape* never depends on where it is written, only on `-v`/`--verbose` — and only for how *much* ends up in it, never whether `meta`/`data_type`/`timestamp` are there at all (they always are). Without `-v`, `meta` has just `timestamp`/`data_type`, as shown above. With it, the rest of [`ExecutionMetadata`](https://www.derafu.dev/docs/core/backbone-dispatcher/results#execution-metadata-executionmetadata) (`startedAt`, timing, memory, CPU, load average) is merged into that same `meta` on success, or into `extensions` on failure (next to the already-present `debug`/`context`/`throwable`) — never a new top-level key:

```bash
bin/console -v billing:invoice:builder:build request.json
```

```json
{
    &quot;meta&quot;: {
        &quot;timestamp&quot;: 1755500000.123456,
        &quot;data_type&quot;: &quot;App\\Entity\\Invoice&quot;,
        &quot;startedAt&quot;: &quot;2026-01-20T10:00:00+00:00&quot;,
        &quot;finishedAt&quot;: &quot;2026-01-20T10:00:00+00:00&quot;,
        &quot;realTime&quot;: 0.0234,
        &quot;userTime&quot;: 0.0198,
        &quot;systemTime&quot;: 0.0012,
        &quot;memoryUsed&quot;: 131072,
        &quot;peakMemory&quot;: 4194304,
        &quot;pid&quot;: 12345,
        &quot;loadAverage1Min&quot;: 0.52,
        &quot;loadAverage5Min&quot;: 0.61,
        &quot;loadAverage15Min&quot;: 0.58
    },
    &quot;data&quot;: { &quot;id&quot;: &quot;INV-001&quot; }
}
```

## Exit Codes

`bin/console help &lt;command&gt;` always lists every exit code that specific command can return — the fixed ones below, plus whatever the injected `ExitCodeResolverInterface` reports via `describe()` (see next section).

| Code | Meaning |
| --- | --- |
| `0` | Success. |
| `1` | The operation failed, and nothing more specific applies (`DefaultExitCodeResolver`&#039;s fallback). |
| `10`–`16` | One of `derafu/backbone-dispatcher`&#039;s own 7 generic exceptions — see below. |
| `65` (`EX_DATAERR`) | The request could not be parsed as JSON/YAML/XML. |
| `66` (`EX_NOINPUT`) | The input file does not exist or is not readable. |
| `70` (`EX_SOFTWARE`) | An unexpected internal error — a bug, not a usage or business problem. |
| `73` (`EX_CANTCREAT`) | `--output`/`--error-output` could not be created. |

`65`/`66`/`70`/`73` are [`sysexits(3)`](https://man.freebsd.org/cgi/man.cgi?query=sysexits) codes — a real BSD convention (`/usr/include/sysexits.h` on macOS/BSD), chosen because they start at `64` specifically to avoid colliding with whatever small integers other programs already use for their own exit codes. `2` (Symfony Console&#039;s own `Command::INVALID`) is reserved but currently unused by this package.

### `ExitCodeResolverInterface`: Mapping Exceptions to Codes

Every failure not caused by this command itself (a `ProblemDetailInterface`, produced by the dispatch) goes through `ExitCodeResolverInterface::resolve()`. `DefaultExitCodeResolver` — the default — already maps the 7 exceptions generic to *any* Backbone-based project, since they mean the same thing regardless of domain:

| Code | Exception |
| --- | --- |
| `10` | `OperationNotFoundException` |
| `11` | `OperationNotAllowedException` |
| `12` | `MissingParameterException` |
| `13` | `InvalidParameterTypeException` |
| `14` | `ClassNotFoundException` |
| `15` | `FromArrayMethodNotFoundException` |
| `16` | `NoDeserializerFoundException` |

A project that also wants its *own* business exceptions mapped extends `DefaultExitCodeResolver` rather than starting from nothing, layering its mapping on top via `parent::`:

```php
use Derafu\BackboneConsole\Service\DefaultExitCodeResolver;
use Derafu\BackboneDispatcher\Contract\ProblemDetailInterface;

class MyExitCodeResolver extends DefaultExitCodeResolver
{
    public function resolve(ProblemDetailInterface $problem): int
    {
        return match ($problem-&gt;getThrowable()-&gt;getClass()) {
            MyBusinessException::class =&gt; 20,
            default =&gt; parent::resolve($problem),
        };
    }

    public function describe(): array
    {
        return [MyBusinessException::class =&gt; 20] + parent::describe();
    }
}
```

Codes `&gt;= 10` are the convention for a project&#039;s own mapping — clear of Symfony Console&#039;s `0`/`1`/`2`, `DefaultExitCodeResolver`&#039;s own `10`–`16`, and the `64`–`78` `sysexits(3)` range this package uses internally.





---
Last updated on 21/10/2026
#php
