jovian / metal
macOS Metal Bindings for The PHP Metal Extension
Requires
- php: ^8.4|^8.5|^8.6
- ext-metal: ^0.8.0
Requires (Dev)
- pestphp/pest: ^4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-17 17:32:18 UTC
README
Metal, typed in PHP.
ext-metal binds Apple's Metal 1:1 and deliberately withholds one thing:
constants. This package is where they live, along with the types the extension
cannot express — every object handle projected onto a class, every flattened
struct onto a value object, every NS_ENUM onto a PHP enum mined from the
macOS SDK.
It adds no behaviour. Every method is exactly one extension call.
ext-metal → jovian/metal → venusian-metal → Surface
1:1 typed composition cross-platform
Install
composer require jovian/metal
Requires macOS, PHP ^8.4, and ext-metal ^0.8.0.
The shape of it
use Jovian\Bindings\Metal\Enums\MTLPixelFormat; use Jovian\Bindings\Metal\Enums\MTLStorageMode; use Jovian\Bindings\Metal\Enums\MTLTextureUsage; use Jovian\Bindings\Metal\MTL\MTLDevice; use Jovian\Bindings\Metal\MTL\MTLTextureDescriptor; $device = MTLDevice::createSystemDefault(); echo $device->name(); // "Apple M1 Pro" $descriptor = MTLTextureDescriptor::texture2DDescriptorWithPixelFormatWidthHeightMipmapped( MTLPixelFormat::RGBA8_UNORM, // 70, mined from MTLPixelFormat.h 64, 64, false, ); $descriptor->setUsage(MTLTextureUsage::RENDER_TARGET->value); $descriptor->setStorageMode(MTLStorageMode::SHARED); $texture = $device->newTextureWithDescriptor($descriptor->handle); $texture->width(); // 64
Three conventions are worth knowing up front.
Object parameters are int handles; only returns are boxed. Pass
$obj->handle. The extension takes handles, and a projection that quietly
unwrapped objects would be doing work the extension did not ask for.
NS_OPTIONS values stay int. PHP enums cannot be OR'd, so
MTLTextureUsage::RENDER_TARGET | MTLTextureUsage::SHADER_READ is a
TypeError. The enum is still generated — write ->value and OR the ints.
PHP refcount owns the handle. A projected object retains on construction and releases when the last PHP reference goes. So this is a bug:
$handle = MTLTextureDescriptor::texture2D…(…)->handle; // already released
Hold the object, then read ->handle.
Structs and the NSError** return
Flattened struct arguments are value objects:
use Jovian\Bindings\Metal\Values\MTLClearColor; use Jovian\Bindings\Metal\Values\MTLViewport; $attachment->setClearColor(new MTLClearColor(0.0, 0.0, 0.0, 1.0)); $encoder->setViewport(new MTLViewport(0.0, 0.0, 64.0, 64.0, 0.0, 1.0));
Shader compilation and pipeline creation use ext-metal's sanctioned
{handle, error} return, typed here as HandleError:
$result = $device->newLibraryWithSourceOptionsError($source, 0); if ($result->handle === 0) { // note: handle, not error throw new RuntimeException( $result->error > 0 ? (Bridge::errorDescription($result->error) ?? 'unreadable NSError') : 'the call could not be made at all', ); } $library = MTLLibrary::box($result->handle);
Both members are zero when the binding refused to send at all — a nil receiver,
or an argument that did not resolve — so handle === 0 is the failure test, not
error > 0.
Proof
examples/proof_triangle_typed.php compiles an MSL shader pair at runtime,
builds a render pipeline, draws one triangle into a 64×64 offscreen texture and
byte-checks the readback. No window, no AppKit.
$ php examples/proof_triangle_typed.php
device: Apple M1 Pro
centre: 64,128,191,255 corner: 0,0,0,255
PROOF_TRIANGLE_TYPED_OK
Those are the same bytes ext-metal's own proof_triangle asserts — the same
GPU work, written against types instead of cited integer literals.
What is here
| Classes | 32 (src/MTL/, src/QuartzCore/) |
| Methods | 157, each exactly one extension call |
| Enums | 18, 267 cases, mined from the SDK |
| Value objects | 9 (src/Values/) |
| Hand-written | src/Runtime/ — Bridge, ObjCObject, Registry, Lifetime |
src/MTL/**, src/QuartzCore/**, src/Enums/** and src/Runtime/ClassMap.php
are generated by php scripts/generate.php and are never hand-edited.
Cross-extension pointer seam
Raw pointer bits are the only currency between extensions — never a registry handle from another one.
$layer = CAMetalLayer::init(); $pointer = Bridge::pointerOf($layer->handle); // … hand $pointer to ext-appkit's own Bridge::adopt('CAMetalLayer', $pointer)
Each side owns one retain, and neither may skip its own on the assumption the other did it.
Development
See AGENTS.md for the rules and the gate list, and
.okf/ for the knowledge bundle.
php scripts/generate.php --check vendor/bin/pest
Licence
MIT.