owenvoke/gitea
A Laravel/PHP client for the Gitea API, providing a clean SDK and Laravel integration to work with repositories, issues, users, organizations, and other Gitea endpoints.
Install and wire it up yourself. There is no service provider, no publishable config, and no facade — this is a plain Composer library built on Saloon, and Laravel integration is entirely your job:
composer require owenvoke/gitea
// config/services.php
'gitea' => [
'url' => env('GITEA_URL'), // https://gitea.example.com (no /api/v1 here)
'token' => env('GITEA_TOKEN'),
'timeout' => env('GITEA_TIMEOUT', 10),
],
Bind one configured instance as a singleton. Resolve it once per request/worker and inject it; don't new Gitea(...) at call sites:
// AppServiceProvider::register()
$this->app->singleton(Gitea::class, fn () => new Gitea(
baseUrl: rtrim(config('services.gitea.url'), '/').'/api/v1',
token: config('services.gitea.token'),
));
Look at src/Resources/ before you plan anything. That directory is the actual feature list — repositories, issues, users, organisations. If a noun isn't there, the endpoint isn't wrapped and it's your code. src/Data/ is the second stop: it tells you where ->dto() will return a typed object and where you'll be hand-mapping arrays.
First use case: prove the round trip in ten minutes. One read and one write exercises auth, base URL, JSON encoding, and response parsing:
$gitea = app(Gitea::class);
$repo = $gitea->repositories()->get('acme', 'platform')->throw()->json();
$issue = $gitea->issues()->create('acme', 'platform', [
'title' => 'Smoke test',
'body' => 'Created from Laravel',
])->throw()->json('number');
If the read 404s, it's almost always the base URL. If it 401s, it's the token or its scope. Get past both before writing a single line of feature code.
Put a domain service between your app and the connector. Saloon's Response should never reach a controller, a job, or a Blade view. One class per noun, container-injected, returning your own types:
namespace App\Gitea;
class GiteaIssues
{
public function __construct(private Gitea $client) {}
public function open(string $owner, string $repo, string $title, string $body): int
{
return $this->client->issues()
->create($owner, $repo, compact('title', 'body'))
->throw()
->json('number');
}
}
That wrapper is your swap seam. When the package falls behind a Gitea release, you rewrite three method bodies instead of grepping the codebase for issues()->create.
Paginate with a generator that stops on an empty page. Gitea's paging metadata is unreliable across endpoints, so never trust X-Total-Count:
public function all(string $owner, string $repo): \Generator
{
for ($page = 1; $page <= 100; $page++) { // hard cap = runaway guard
$items = $this->client->issues()
->all($owner, $repo, ['page' => $page, 'limit' => 50, 'state' => 'all'])
->throw()
->json();
if (empty($items)) {
return;
}
yield from $items;
}
}
Every write goes on a queue. Gitea latency is your latency, and retries belong in one place:
class PushIssueToGitea implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [10, 60, 300, 900];
public function handle(GiteaIssues $issues): void
{
if ($this->deployment->gitea_issue_number) {
return; // idempotency: retry after a timeout must not double-post
}
$number = $issues->open('acme', 'platform', $this->title(), $this->body());
$this->deployment->update(['gitea_issue_number' => $number]);
}
}
Pair that early return with a unique index on (deployment_id, gitea_issue_number) — the container will not save you from a duplicate comment.
Default to ->json('field'), opt in to ->dto(). DTOs only exist for a subset of resources and they're not exhaustive. Use them where they're typed and stable; otherwise pull scalars out of the array and map into your own value object. Your value objects are the contract you control.
Verify webhooks against the raw body, then dispatch immediately.
Route::post('/webhooks/gitea', function (Request $request) {
$expected = hash_hmac('sha256', $request->getContent(), config('services.gitea.webhook_key'));
abort_unless(hash_equals($expected, (string) $request->header('X-Gitea-Signature')), 401);
ProcessGiteaWebhook::dispatch($request->json()->all());
return response()->noContent();
})->withoutMiddleware(['web']);
Hash $request->getContent(), not $request->all(). A re-serialised body never matches, and the mismatch looks identical to a wrong secret — you'll burn an hour on the wrong suspect.
Test with Saloon's MockClient, not Http::fake(). Saloon doesn't route through Laravel's HTTP client, so Http::fake() silently lets real requests escape to your Gitea instance:
$mock = new MockClient([
CreateIssue::class => MockResponse::make(['number' => 42], 201),
]);
app(Gitea::class)->withMockClient($mock);
// act...
$mock->assertSent(CreateIssue::class);
This also lets you assert the outgoing payload, which is where most integration bugs actually live — a missing field, a wrong enum, a null body.
Cache reads that a user is waiting on, sync the rest. Short-TTL caching on repo/issue lookups turns a 400ms page into a 5ms one. Everything non-interactive belongs in a scheduled command — build gitea:sync early, because it doubles as your recovery mechanism after an outage, a 429 storm, or a half-applied batch.
Log at the connector, once. Use Saloon middleware to emit a single structured record per request (path, status, duration) rather than sprinkling Log::info through job handlers:
$gitea->middleware()->onResponse(function ($response) {
Log::info('gitea.request', [
'status' => $response->status(),
'path' => $response->getPsrRequest()->getUri()->getPath(),
'duration' => $response->getTransferStats()?->getTransferTime(),
]);
}, 'gitea.log');
->throw() is never automatic. Saloon returns the response whatever the status. ->json('number') on a 404 returns null, and the failure surfaces three layers later as a confusing type error. Call ->throw() on every call, or configure the connector to always throw./api/v1. A bare host or a stray trailing slash produces 404s that look like auth failures. Log the resolved URL once at boot during the spike and never think about it again.app(Gitea::class) is the same instance twice.X-Total-Count may be missing. It depends on endpoint and server config. Loop until an empty page, and always cap the page count.PATCHES.md for local patches, and write the fork trigger into an ADR while things are calm.gitea:sync be the net underneath.How can I help you explore Laravel packages today?