mateusjunges/confluent-schema-registry-api
A PHP library for consuming the Confluent Schema Registry REST API, offering low-level PSR-7 compliant requests plus high-level abstractions for easier developer experience. Includes async and sync APIs, caching support, and integration with Avro and Guzzle.
Install the package (9.1.0+ now allows Guzzle 8, but still supports Guzzle 7):
composer require flix-tech/confluent-schema-registry-api:^9.1 guzzlehttp/guzzle:^8.0
If your Laravel app already pulls Guzzle 7 (Laravel 10/11/12 pin guzzlehttp/guzzle: ^7.8 via illuminate/http), do not force Guzzle 8 — just install the package and let Composer resolve the existing Guzzle 7:
composer require flix-tech/confluent-schema-registry-api:^9.1
composer why guzzlehttp/guzzle # confirm which major you actually got
Both majors are supported by the package; mixing is not.
Configure the HTTP client (e.g. in config/services.php):
'schema-registry' => [
'url' => env('SCHEMA_REGISTRY_URL', 'http://localhost:8081'),
'auth' => [
'basic' => [
'username' => env('SCHEMA_REGISTRY_USER'),
'password' => env('SCHEMA_REGISTRY_PASSWORD'),
],
],
],
Basic usage in a Laravel service:
use FlixTech\SchemaRegistryApi\Registry\BlockingRegistry;
use FlixTech\SchemaRegistryApi\Registry\PromisingRegistry;
use GuzzleHttp\Client;
use FlixTech\AvroPhp\AvroSchema;
class SchemaService
{
protected BlockingRegistry $registry;
public function __construct()
{
$client = new Client([
'base_uri' => config('services.schema-registry.url'),
'auth' => config('services.schema-registry.auth.basic'),
]);
$this->registry = new BlockingRegistry(new PromisingRegistry($client));
}
public function registerSchema(string $subject, string $schemaJson): int
{
return $this->registry->register($subject, AvroSchema::parse($schemaJson));
}
}
BlockingRegistry — the class you will use 95% of the time in Laravel (synchronous controllers, commands, jobs).PromisingRegistry — the underlying promise-based implementation; read this if you need async or want to understand error propagation.CachedRegistry + CacheAdapterInterface — your performance lever in high-traffic apps.Use the package to resolve/verify a schema before accepting a Kafka payload:
use FlixTech\SchemaRegistryApi\Registry\BlockingRegistry;
use FlixTech\AvroPhp\AvroSchema;
class KafkaWebhookController extends Controller
{
public function __construct(private BlockingRegistry $registry) {}
public function handleWebhook(Request $request)
{
$schema = AvroSchema::parse($request->input('schema'));
$schemaId = $this->registry->schemaId('webhook-payload', $schema);
// Proceed with the validated schema ID
}
}
BlockingRegistry): the right default for Laravel routes, commands and queue jobs, where blocking is already the norm.
$schemaId = $registry->register('user-profile', $schema);
PromisingRegistry): useful only where you genuinely pipeline multiple registry calls (e.g. registering a batch of subjects in one job).
$promise = $registry->register('user-profile', $schema);
$promise->then(fn ($schemaId) => $schemaId);
In practice, wrap async work in a queue job and call ->wait() once — Laravel has no event loop to resolve promises otherwise.Reduce registry calls in high-traffic apps:
use FlixTech\SchemaRegistryApi\Registry\CachedRegistry;
use FlixTech\SchemaRegistryApi\Registry\Cache\DoctrineCacheAdapter;
use Doctrine\Common\Cache\ArrayCache;
$cachedRegistry = new CachedRegistry(
$promisingRegistry,
new DoctrineCacheAdapter(new ArrayCache())
);
$schemaId = $cachedRegistry->register('user-profile', $schema);
In Laravel you almost always want a shared store (Redis/APCu) rather than ArrayCache, which is per-process and therefore per-request.
Bind the registry once in AppServiceProvider:
public function register(): void
{
$this->app->singleton(BlockingRegistry::class, function () {
$client = new Client([
'base_uri' => config('services.schema-registry.url'),
'auth' => config('services.schema-registry.auth.basic'),
]);
return new BlockingRegistry(new PromisingRegistry($client));
});
}
Then inject it anywhere:
public function __construct(BlockingRegistry $registry) { /* ... */ }
public function up(): void
{
$schema = AvroSchema::parse(file_get_contents(database_path('schemas/user.avsc')));
$schemaId = app(BlockingRegistry::class)->register('user', $schema);
Schema::create('users', function (Blueprint $table) use ($schemaId) {
$table->id();
$table->unsignedInteger('schema_id')->default($schemaId);
});
}
Prefer an artisan command or deployment step over migrations — migrations should not depend on an external service being reachable.
use FlixTech\SchemaRegistryApi\Exception\SchemaRegistryException;
try {
$schemaId = $this->registry->register('user-profile', $schema);
} catch (SchemaRegistryException $e) {
report($e);
throw new \RuntimeException('Schema registration failed', 0, $e);
}
Catch at the boundary (job/controller), not deep inside services — registry exceptions are infrastructure failures, not domain errors.
use FlixTech\SchemaRegistryApi\Requests\Functions;
$request = Functions::deleteSchema($client, 'subject', 1);
$response = $client->send($request);
This is how you reach endpoints the high-level registries do not expose (deletes, compatibility checks, mode switches).
Guzzle 8 vs Laravel's HTTP client (new in 9.1.0)
guzzlehttp/guzzle:^8, guzzlehttp/promises:^3, guzzlehttp/psr7:^3) while keeping Guzzle 7 compatibility.guzzlehttp/guzzle:^7.8, so a standard Laravel app cannot upgrade to Guzzle 8 without conflicting with the framework. The package supports both, so this is not a blocker — just don't add guzzlehttp/guzzle:^8 to require in a Laravel project.composer why guzzlehttp/guzzle and composer why guzzlehttp/psr7 after upgrading.composer.json ("guzzlehttp/guzzle": "^7.8") so a future transitive resolution doesn't silently swap HTTP stacks under you.PSR-7 3 / Promises 3 type changes
StreamInterface/ResponseInterface fakes for tests, or a custom PromiseInterface implementation, they must be updated to the v3 signatures. Prefer GuzzleHttp\Psr7\HttpFactory and GuzzleHttp\Promise\Create::promiseFor() over hand-rolled implementations.MockHandler / HandlerStack are generally source-compatible, but re-run your schema-registry test suite after the bump.Schema Registry Compatibility
flix-tech/avro-php). Protobuf/JSON Schema require pre-processing or extension.Caching Quirks
CachedRegistry will happily serve stale IDs after a schema update. Use a TTL cache or a custom adapter.md5 hashing is fine in practice but configurable:
$cachedRegistry = new CachedRegistry($promisingRegistry, $cache, 'sha1');
Asynchronous Edge Cases
->then() will surface as a fatal error at ->wait().
$promise
->then(fn ($schemaId) => $schemaId)
->otherwise(function (SchemaRegistryException $e) {
report($e);
throw $e;
})
->wait();
->wait() in a bounded context (a job), never in a long-running worker loop without resolution.wait() return types are stricter; don't rely on loose truthiness of the resolved value.Authentication
'auth' => ['user', 'pass'], // Basic
// 'headers' => ['Authorization' => 'Bearer ' . $token], // Bearer
Performance & Rate Limiting
$job->release($seconds)) rather than in the exception handler.Guzzle logging middleware (API differs slightly between Guzzle 7 Middleware::tap and Guzzle 8 — check the docs for your installed major):
$stack = HandlerStack::create();
$stack->push(Middleware::tap(function ($request) {
Log::debug('Schema Registry Request', [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'body' => (string) $request->getBody(),
]);
}));
$client = new Client([
'base_uri' => config('services.schema-registry.url'),
'handler' => $stack,
]);
Note: (string) $request->getBody() consumes the stream — rewind it ($request->getBody()->rewind()) before the request is sent if you see empty request bodies.
Health check route:
Route::get('/schema-registry/health', function () {
$client = new Client(['base_uri' => config('services.schema-registry.url')]);
$client->get('/subjects');
return response()->json(['status' => 'healthy']);
});
Version skew after upgrade: if you see Class "GuzzleHttp\Promise\..." not found or signature-mismatch errors after bumping to 9.1.0, run composer update guzzlehttp/* --with-dependencies — a stale promises 2.x alongside psr7 3.x is the usual culprit.
Custom cache adapters — implement CacheAdapterInterface for Redis/Memcached or to add TTL + invalidation:
use FlixTech\SchemaRegistryApi\Registry\Cache\CacheAdapterInterface;
use Psr\SimpleCache\CacheInterface;
final class PsrCacheAdapter implements CacheAdapterInterface
{
public function __construct(private CacheInterface $cache) {}
public function get(string $key)
{
return $this->cache->get($key);
}
public function put(string $key, $value): void
{
$this->cache->set($key, $value, 3600);
}
public function has(string $key): bool
{
return $this->cache->has($key);
}
}
Bind it to a Laravel cache store (Cache::store('redis')) so cache invalidation is shared across queue workers.
Custom hash strategy — pass a callable/algorithm name to CachedRegistry when md5 key derivation doesn't fit.
Decorating the registry — wrap BlockingRegistry in your own class implementing the same registry interface to add metrics, logging, or circuit breaking without touching call sites:
$this->app->singleton(BlockingRegistry::class, fn () => new MetricsRegistry(
new BlockingRegistry(new PromisingRegistry($client))
));
Custom Guzzle handlers — inject a HandlerStack (retry middleware, tracing, mock handler in tests) straight into the Client you hand to PromisingRegistry; the package never constructs its own client, so this is the cleanest seam for cross-cutting HTTP concerns.
How can I help you explore Laravel packages today?