corgab / aether
Laravel Quantum Computing bridge for AWS Braket and local simulators
Requires
- php: ^8.3
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/support: ^13.0
- symfony/process: ^7.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pao: ^1.0.6
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-09-02 08:57:12 UTC
README
Laravel package for quantum computing via AWS Braket and local simulators.
Build quantum circuits, generate hardware-grade entropy, and swap backends with a single config change — all with a fluent, Laravel-native API.
Requirements
- PHP 8.3+
- Laravel 13
- Python 3.11+ with
amazon-braket-sdk(CI runs 3.11 and 3.12) - A running queue worker, if you use asynchronous execution
Installation
composer require corgab/aether
Run the install command to publish the config, check Python dependencies, and verify your setup:
php artisan aether:install
This will optionally create a .aether-venv virtual environment and install the required Python packages for you.
Configuration
Publish the config file if you haven't already:
php artisan vendor:publish --tag=aether-config
Add to your .env:
AETHER_DRIVER=local AETHER_PYTHON_PATH=python3
For AWS Braket:
AETHER_DRIVER=aws AWS_DEFAULT_REGION=us-east-1 AETHER_S3_BUCKET=your-bucket AETHER_DEVICE_ARN=arn:aws:braket:::device/quantum-simulator/amazon/sv1
AETHER_S3_BUCKET is required by the aws driver, together with the region and the device ARN: a missing or empty value throws an InvalidDriverConfigException on every call, including a synchronous ->run() against the SV1 simulator. Braket writes the task results to s3://<bucket>/results.
Usage
Quantum Circuits
use Aether\Facades\Quantum; $result = Quantum::circuit() ->qubits(2) ->h(0) ->cnot(0, 1) ->measure() ->shots(1024) ->run(); $result->counts(); // ['00' => 512, '11' => 512] $result->probabilities(); // ['00' => 0.5, '11' => 0.5] $result->mostFrequent(); // '00' $result->count('00'); // 512 — measurement count for a single bitstring $result->count(); // 2 — number of distinct outcomes (Countable) $result->probability('00'); // 0.5 — probability of a single bitstring $result->shots(); // 1024 — total measurements $result->outcomes(); // ['00', '11'] — bitstrings sorted by count, descending
CircuitBuilder exposes read-only introspection on the circuit being built:
$builder = Quantum::circuit()->qubits(2)->h(0)->cnot(0, 1)->measure(); $builder->gateCount(); // 2 — number of gates, excluding measurement $builder->depth(); // 2 — number of sequential layers, excluding measurement
Entropy Generation
$entropy = Quantum::entropy(); $bytes = $entropy->generate(256); // 32 raw bytes $hex = $entropy->hex(128); // 32-char hex string $roll = $entropy->integer(1, 6); // unbiased die roll (rejection sampling)
Batch Execution
Run several circuits in a single Python process instead of paying the interpreter start-up cost once per circuit. The results come back as a BatchResult, ordered like the input, which is arrayable, jsonable, countable and iterable over the individual CircuitResult objects.
use Aether\Facades\Quantum; $batch = Quantum::batch([$circuitA, $circuitB])->run(); foreach ($batch as $index => $result) { $result->counts(); // array<string, int> } $batch->get(1)->mostFrequent(); $batch[0]->probabilities();
- Validation: every circuit is validated like a single
->run()would, so a circuit without qubits or withoutmeasure()throwsInvalidCircuitExceptionbefore anything is executed. - Per-circuit shots: each circuit keeps its own
->shots(). On AWS the whole batch is submitted at once with one shot count per task; the local simulator does not support that, so with mixed shot counts the circuits run sequentially inside the same Python process. - Driver mismatch: a circuit pinned to another driver (e.g.
Quantum::circuit('aws')) cannot be run in a batch targeting a different driver —InvalidCircuitException::batchDriverMismatchis thrown. - QPU safety:
synchronous_safeapplies to batches too. A batch->run()on a driver markedsynchronous_safe: falsethrows, exactly like a single->run(). - Contracts: batch-capable drivers implement
Aether\Contracts\BatchableDevice;Quantum::batch()on a driver that does not throwsQuantumExecutionException::batchUnsupported. The coreAether\Contracts\QuantumDevicecontract is unchanged, so third-party drivers keep working.
Asynchronous Execution
Real QPU tasks queue for minutes or hours, so a synchronous ->run() would block the request. Use ->dispatch() instead: the circuit is submitted by a queued job, polled until it reaches a terminal state, and the result is delivered through an event.
use Aether\Facades\Quantum; Quantum::circuit('aws') ->qubits(2) ->h(0) ->cnot(0, 1) ->measure() ->shots(1000) ->dispatch();
->queue('quantum') does the same on a specific queue, and both return Laravel's PendingDispatch, so the usual chaining works:
Quantum::circuit('aws')->qubits(1)->h(0)->measure()->dispatch()->onQueue('quantum');
Listen for the result:
use Aether\Events\CircuitCompleted; use Illuminate\Support\Facades\Event; Event::listen(function (CircuitCompleted $event) { $event->result->counts(); // ['00' => 503, '11' => 497] $event->taskArn; // 'arn:aws:braket:...:quantum-task/...' $event->driver; // 'aws' });
Tune the polling in config/aether.php:
AETHER_QUEUE=quantum AETHER_POLL_INTERVAL=5 AETHER_MAX_POLL_ATTEMPTS=720
PollQuantumTask re-checks the task with Laravel's job release(), waiting AETHER_POLL_INTERVAL seconds between attempts, so asynchronous AWS execution needs a real queue connection with a running worker (php artisan queue:work). The sync connection is not supported: there release() is a no-op, so polling stops silently after the first non-terminal check — no event, no error. The local driver is unaffected, since its tasks are already terminal on the first poll.
A task that fails or is cancelled throws TaskFailedException from the polling job; one that never finishes within max_poll_attempts throws QuantumExecutionException. Both land in failed_jobs with the task ARN in the message, so you can inspect the task in the AWS console. The job declares $maxExceptions = 1, so any exception fails it immediately without retries — the re-check loop is driven by release(), not by queue retries.
The local simulator supports ->dispatch() too — it executes immediately and caches the result under a synthetic local: task id, so you can develop the full asynchronous flow without touching AWS.
Task Persistence
You can optionally record every asynchronously dispatched task into a database table.
php artisan vendor:publish --tag=aether-migrations php artisan migrate
Set AETHER_PERSIST_TASKS=true in your .env.
When enabled, Aether inserts a row into quantum_tasks containing the circuit, shots, and driver when a task is dispatched, and updates its status and counts as the polling job progresses. The status always mirrors the backend's real state. Polling problems (like exhaustion or malformed responses) are logged in error and failed_at.
Since persistence is strictly best-effort, a database failure never affects queue behaviour or prevents the CircuitCompleted event from being emitted.
use Aether\Models\QuantumTask; use Aether\Tasks\TaskStatus; $runningTasks = QuantumTask::where('status', TaskStatus::Running)->get();
Switching Drivers
// Use the default driver Quantum::circuit()->qubits(1)->h(0)->measure()->run(); // Use a specific driver Quantum::circuit('aws')->qubits(1)->h(0)->measure()->run();
Custom Drivers
use Aether\Facades\Quantum; Quantum::extend('my-driver', fn () => new MyQuantumDriver()); Quantum::driver('my-driver')->executeCircuit($circuit);
Custom Providers
A custom driver usually needs a matching backend on the Python side. Instead of forking the bin/python scripts, declare a provider: a plain Python module that resolves the device the scripts run circuits on. The scripts pick it from the python_provider key of the driver's config — either a filesystem path to a .py file or an importable module name — falling back to the built-in providers for the local and aws drivers.
A provider module may define four module-level hooks; only the first is required:
| Hook | Required | Purpose |
|---|---|---|
resolve_device(config) -> Device |
yes | Return a Braket-compatible device: .run(circuit, shots=..., **opts) returning a task with .id and .result() (whose result exposes measurement_counts). Raise ValueError with a human-readable message on bad config. |
run_options(config) -> dict |
no | Extra kwargs merged into every device.run() call (the aws provider returns the S3 destination folder here). Defaults to {}. |
run_batch(device, circuits, shots_list, config) -> list[Result] |
no | Full control over batch execution. Without it, uniform shot counts go through one device.run_batch() call and mixed shot counts run sequentially. |
check_task(task_id, config) -> dict |
no | Return {"status": "<CREATED|QUEUED|RUNNING|COMPLETED|FAILED|CANCELLED>"}, plus "counts" when COMPLETED. Without it, task polling fails with Driver '<name>' does not support task polling. |
config is the driver's config array from config/aether.php, passed through the JSON payload — providers should read their settings from it, not from environment variables. A minimal provider:
# app/quantum/ionq_provider.py def resolve_device(config): from braket.aws import AwsDevice return AwsDevice(config["device_arn"])
Wire it up with a driver registered through Quantum::extend() — Quantum::bridge() hands you the same configured PythonBridge the built-in drivers use:
// config/aether.php 'drivers' => [ 'ionq' => [ 'device_arn' => 'arn:aws:braket:us-east-1::device/qpu/ionq/Aria-1', 'python_provider' => base_path('app/quantum/ionq_provider.py'), 'synchronous_safe' => false, ], ],
use Aether\Facades\Quantum; Quantum::extend('ionq', fn () => new IonqDriver( Quantum::bridge(), config('aether.drivers.ionq'), ));
where IonqDriver extends Aether\Drivers\AbstractQuantumDriver and returns 'ionq' from driverName() — the base class already implements circuit execution, batching, and entropy generation on top of the scripts.
Trust model.
python_providerexecutes arbitrary Python inside the subprocess — it is exactly as powerful aspython_pathitself. Treat it as trusted code: set it only from configuration you control, and never derive it from user input.
Available Gates
| Method | Description |
|---|---|
h($qubit) |
Hadamard |
x($qubit) |
Pauli-X (NOT) |
y($qubit) |
Pauli-Y |
z($qubit) |
Pauli-Z |
i($qubit) |
Identity |
s($qubit) |
Phase-S |
si($qubit) |
Phase-S† (adjoint S) |
t($qubit) |
Phase-T |
ti($qubit) |
Phase-T† (adjoint T) |
rx($qubit, $angle) |
Rotation around the X-axis (float radians or Angle) |
ry($qubit, $angle) |
Rotation around the Y-axis (float radians or Angle) |
rz($qubit, $angle) |
Rotation around the Z-axis (float radians or Angle) |
phaseshift($qubit, $angle) |
Phase shift (float radians or Angle) |
u($qubit, $theta, $phi, $lambda) |
Universal single-qubit rotation |
cnot($control, $target) |
Controlled-NOT |
cz($control, $target) |
Controlled-Z |
cy($control, $target) |
Controlled-Y |
crx($control, $target, $angle) |
Controlled-RX (float radians or Angle) |
cry($control, $target, $angle) |
Controlled-RY (float radians or Angle) |
crz($control, $target, $angle) |
Controlled-RZ (float radians or Angle) |
cphaseshift($control, $target, $angle) |
Controlled-PhaseShift (float radians or Angle) |
swap($qubit0, $qubit1) |
SWAP |
iswap($qubit0, $qubit1) |
iSWAP |
xx($qubit0, $qubit1, $angle) |
Ising XX coupling |
yy($qubit0, $qubit1, $angle) |
Ising YY coupling |
zz($qubit0, $qubit1, $angle) |
Ising ZZ coupling |
ccnot($control0, $control1, $target) |
Toffoli (CCNOT) |
cswap($control, $qubit0, $qubit1) |
Controlled-SWAP (Fredkin) |
measure($targets) |
Measurement (null = all qubits) |
Circuit Composition
Reusable sub-circuits can be appended to a circuit with append(), passing either another builder or a closure that receives an isolated builder with the same qubit count. Measurement gates in the fragment are dropped, so the parent circuit decides where to measure:
$bell = Quantum::circuit()->qubits(2)->h(0)->cnot(0, 1); $result = Quantum::circuit() ->qubits(2) ->append($bell) ->append(fn ($c) => $c->rz(0, M_PI / 4)) ->measure() ->run();
Appending a fragment that requires more qubits than the circuit has throws an InvalidCircuitException.
Adding a Gate
Gate knowledge lives in a single metadata layer on each side of the bridge: the GateType / GateShape enums in src/Circuit/ (PHP) and the GATE_PARAMS table in bin/python/common.py (Python). Adding a gate touches exactly five places:
- A
GateTypecase (and, for a new parameter shape, aGateShapecase) - A static factory on
Gate - A fluent method on
CircuitBuilder(a one-liner delegating topush()) - A
GATE_PARAMSrow inbin/python/common.py - A row in the gate table above
Everything else is derived from the metadata. The test suite enforces completeness: a GateType case without a factory, fluent method, or wire-contract dataset entry fails the Unit suite, and a PHP/Python mismatch fails tests/Feature/GateParityTest.php, which compares the two tables through the real Python bridge.
Events
Aether dispatches events at each execution choke point, so application code can react without coupling to a specific driver call.
| Event | When | Payload |
|---|---|---|
CircuitExecuted |
A circuit finishes executing synchronously (->run(), or once per circuit of a Quantum::batch()->run()) |
driver (string), circuit (the toArray() definition), result (CircuitResult) |
EntropyGenerated |
A device generates entropy (EntropyGenerator::generate()/hex()/integer()) |
driver (string), bits (int, the requested bit count) |
CircuitCompleted |
An asynchronously dispatched task (->dispatch()) reaches a terminal state |
driver (string), circuit, result (CircuitResult), taskArn (?string) — see Asynchronous Execution |
EntropyGenerated deliberately never carries the generated bytes: entropy typically feeds tokens, keys or nonces, so exposing the value to every registered listener would defeat the point of keeping it secret. Capture EntropyGenerator::generate()/hex()/integer()'s return value directly if you need the material itself.
None of these events fire when execution fails — a malformed response or a driver exception is raised before the event is dispatched.
use Aether\Events\CircuitExecuted; use Aether\Events\EntropyGenerated; use Illuminate\Support\Facades\Event; Event::listen(function (CircuitExecuted $event) { $event->result->counts(); // ['00' => 503, '11' => 497] $event->driver; // 'local' }); Event::listen(function (EntropyGenerated $event) { $event->bits; // 256 $event->driver; // 'aws' });
Quantum::fake() dispatches CircuitExecuted and EntropyGenerated too, mirroring the real drivers, so Event::fake() assertions on application code keep working the same way whether or not the backend itself is faked. CircuitExecuted fires only for synchronous execution (->run() and Quantum::batch()->run()): a local ->dispatch() runs the simulator inline but announces itself through CircuitCompleted alone, like the aws driver.
Testing
Aether provides a Quantum::fake() method that works like Http::fake() or Mail::fake():
use Aether\Facades\Quantum; $fake = Quantum::fake(); // Run your application code... $fake->assertCircuitRan(); $fake->assertCircuitRan(fn ($circuit) => $circuit->qubitCount() === 2); $fake->assertEntropyGenerated(256);
Batch executions are recorded as well. Every circuit in a batch also counts as an executed circuit, so assertCircuitRan() sees it:
$fake->assertBatchRan(); $fake->assertBatchRan(fn (array $circuits) => count($circuits) === 2); $fake->assertBatchNotRan(); $fake->assertBatchRanTimes(1);
Asynchronously dispatched circuits are recorded separately:
$fake->assertCircuitDispatched(); $fake->assertCircuitDispatched(fn ($circuit) => $circuit->shotCount() === 1000); $fake->assertCircuitDispatchedTimes(2); $fake->assertCircuitNotDispatched();
Stub what the fake returns, the same way Http::fake() accepts stubbed responses:
// A canned counts array, returned by every executed circuit $fake = Quantum::fake(['00' => 700, '11' => 324]); // Or a canned CircuitResult, built with QuantumFake::result() $fake = Quantum::fake(QuantumFake::result(['00' => 700, '11' => 324])); // A closure evaluated per circuit — branch on the CircuitBuilder about to run $fake = Quantum::fake(function (CircuitBuilder $circuit) { return $circuit->qubitCount() === 2 ? ['00' => 1000] : null; // null falls through to the default }); // An ordered sequence, one result per call — throws once exhausted, unless whenEmpty() is set $fake = Quantum::fake( QuantumFake::sequence([ ['0' => 10], ['1' => 10], ])->whenEmpty(['0' => 5, '1' => 5]) );
All forms above can also be set after fake() (or changed later) with respondWith(), and respondWithCounts(array $counts) remains available as a shorthand for the plain counts-array form. Calling checkTask() for an asynchronously submitted circuit honours the same stub: the result is resolved on the first successful poll and kept for that task, so polling it repeatedly consumes a single sequence entry, like a real completed task.
Stubbed counts must be shaped like real measurement counts — bitstring keys ("00", "1", ...) and non-negative integer values — or Quantum::fake() / respondWith() throw an InvalidArgumentException immediately. An empty array is accepted, so the empty-result branch (mostFrequent() unavailable, zero shots) can be exercised too.
Entropy can be stubbed independently of circuit results:
$fake->respondEntropyWith("\xFF"); // fixed bytes, tiled to whatever length each call needs $fake->respondEntropyWith(QuantumFake::hex('ff00')); // fixed bytes from a hex string $fake->respondEntropyWith(fn (int $bits) => null); // closure by bit count; null falls through to the default
Calling Quantum::fake() with no arguments keeps the original deterministic behaviour unchanged: a 50/50 split of 0...0/1...1 counts, and an incrementing byte counter for entropy.
$fake->respondWithTaskStatus(TaskStatus::Running); // simulate a task still in flight
Running the Test Suite
composer test
Synchronous Safety
When using real QPU hardware, requests can take minutes. Set synchronous_safe to false in your driver config to prevent accidental synchronous calls that would block your HTTP request:
// config/aether.php 'aws' => [ 'synchronous_safe' => false, // ... ],
This will throw a QuantumExecutionException on direct calls to ->run(), forcing you to use ->dispatch() instead. Asynchronous submission is never blocked by this flag — that is the path the flag is steering you toward.
Qubit Ceiling
The local simulator keeps a full statevector in memory, and that memory doubles with every additional qubit. To guard against accidentally exhausting host memory, the local driver enforces a max_qubits ceiling (default 25, roughly 512 MB) on every ->run(), ->dispatch(), and Quantum::batch() call:
// config/aether.php 'local' => [ 'max_qubits' => env('AETHER_MAX_QUBITS', 25), // ... ],
A circuit that requests more qubits than the ceiling throws an InvalidCircuitException before any Python subprocess is spawned. Raise AETHER_MAX_QUBITS if your host has memory to spare, or set it to null (or leave AETHER_MAX_QUBITS= empty) to remove the ceiling entirely. The aws driver has no ceiling by default — Braket enforces its own per-device qubit limits — but a max_qubits you configure for it is enforced on ->run(), ->dispatch() and Quantum::batch() alike.
Cost Estimation
The aws driver can estimate the cost of a circuit before it runs, from configured pricing — no AWS Pricing API call is made:
$estimate = Quantum::circuit('aws') ->qubits(2) ->h(0) ->cnot(0, 1) ->measure() ->shots(1000) ->estimateCost(); $estimate->amount; // 0.65 $estimate->currency; // 'USD' $estimate->shots; // 1000 $estimate->breakdown; // ['per_task' => 0.30, 'per_shot' => 0.35] (string) $estimate; // '0.65 USD'
The rates live in config/aether.php and mirror AWS Braket QPU list prices (task + shot) at the time of writing — verify against current AWS pricing before relying on them for budgeting, and override via env vars without a package release:
AETHER_AWS_PRICE_PER_TASK=0.30 AETHER_AWS_PRICE_PER_SHOT=0.00035
Managed simulators (e.g. SV1) bill per-minute instead, so treat simulator estimates as a rough proxy rather than an exact figure. estimateCost() is only available on drivers implementing EstimatesCost; calling it on the local driver (which is free) throws a QuantumExecutionException. Quantum::fake() implements the contract too — every estimate is free by default, and $fake->respondCostWith($estimate) (a CostEstimate or a fn (int $shots, int $tasks): CostEstimate closure) stubs a specific one, so budgeting code stays testable.
Set AETHER_AWS_MAX_COST (or max_cost_per_run in config) to reject a circuit or batch whose estimated cost exceeds it, before any AWS call:
// config/aether.php 'aws' => [ 'max_cost_per_run' => env('AETHER_AWS_MAX_COST', null), // ... ],
The guard runs on ->run(), ->dispatch(), and Quantum::batch() (against the batch's total estimated cost — it bounds what one call can spend). It throws an InvalidCircuitException. null (the default) or an empty AETHER_AWS_MAX_COST= means unlimited — existing configs keep working unchanged. A ceiling configured without pricing rates throws an InvalidDriverConfigException instead of silently never tripping.
License
MIT