Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Gitea Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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');

Gotchas and Tips

  • ->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.
  • Base URL must end in /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.
  • No service provider means no failure signal. Forget the singleton binding and you get a fresh, unconfigured connector per resolution with a null token. Add an assertion to your smoke test that app(Gitea::class) is the same instance twice.
  • There's no retry, backoff, or 429 handling built in. Keep queue worker concurrency bounded and implement backoff in the job. A thundering herd against a self-hosted Gitea is easy to trigger and hard to explain.
  • Set explicit timeouts. The default is generous, and a hung Gitea server will pin your queue workers until they're all blocked. Fail fast; the reconciliation command will pick up the pieces.
  • Self-signed certs are the norm on internal Gitea. Disable TLS verification on this connector only, via the underlying Guzzle options — never globally, and never in a way that leaks into other HTTP clients.
  • Token scope is the top cause of "it worked yesterday." A token that reads repos may not create org webhooks. When a batch dies mid-run, check the token's scopes before you touch code.
  • X-Total-Count may be missing. It depends on endpoint and server config. Loop until an empty page, and always cap the page count.
  • Version drift is real. Community wrappers lag upstream Gitea. After any Gitea upgrade, run a thin contract suite — a handful of reads plus one write — against non-production and treat red as a release blocker.
  • Pin the version exactly. No floating constraint. Keep a PATCHES.md for local patches, and write the fork trigger into an ADR while things are calm.
  • Bulk operations need partial-failure handling. A batch of 500 that dies at 312 leaves half-applied state. Record the last successful index, make the job resumable, and let gitea:sync be the net underneath.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib