Skip to content

About

A Github client that plays nice with Laravel

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

GitHub Client for Laravel

A Laravel package for GitHub's REST API. It uses Saloon for HTTP, maps responses onto PHP objects, and exposes repositories, issues, pull requests, commits, releases, and Actions from a facade or the container.

composer require jordanpartridge/github-client

Tests PHPStan Latest Version Downloads License

composer require installs the latest tag on Packagist. The examples below match the default branch. Github::forInstallation(), Github::withApp(), and installations() are on that branch and are not in the v3.0.0 tag.

Install

composer require jordanpartridge/github-client

Publish the config file:

php artisan vendor:publish --tag=github-client-config

Configure

The package reads a token from config. The published file maps that value from the environment.

GITHUB_TOKEN=ghp_your_token_here

GH_TOKEN is also read, through github-client.gh_token, when GITHUB_TOKEN is empty.

Resolution order in TokenResolver:

  1. config('github-client.token')
  2. config('github-client.gh_token')
  3. gh auth token, when the GitHub CLI is installed and authenticated

If none of those is set, requests go out without a token. Public repositories still respond. Unauthenticated calls use GitHub's lower rate limit.

Create a token at github.com/settings/tokens. Listing the authenticated user's repositories needs a token. Creating issues and pull requests needs a token that can write to the repository.

OAuth and GitHub App settings are in the same config file. See OAuth and GitHub App.

Usage

use JordanPartridge\GithubClient\Facades\Github;

$repos = Github::repos()->all()->dto();

$repo = Github::getRepo('jordanpartridge/github-client');

echo $repo->name;
echo $repo->stargazers_count;
echo $repo->owner->login;

repos()->all() returns a Saloon response. dto() turns the body into a list of RepoData objects. Github::getRepo() accepts an owner/repo string, validates it, and returns one RepoData.

repos()->get() takes a Repo value object, not a string:

use JordanPartridge\GithubClient\ValueObjects\Repo;

$repo = Github::repos()->get(Repo::fromFullName('jordanpartridge/github-client'));

repos()->delete() takes the same value object and returns the Saloon response.

Pagination

all() requests one page (GitHub's default is 30). allWithPagination() follows the Link header and returns every RepoData it can fetch, up to 1000 pages.

use JordanPartridge\GithubClient\Enums\Direction;
use JordanPartridge\GithubClient\Enums\Sort;
use JordanPartridge\GithubClient\Enums\Visibility;

$repos = Github::repos()->allWithPagination(
    visibility: Visibility::PUBLIC,
    sort: Sort::CREATED,
    direction: Direction::DESC,
);

Issues

use JordanPartridge\GithubClient\Enums\Issues\State;

$issue = Github::issues()->create(
    owner: 'jordanpartridge',
    repo: 'github-client',
    title: 'Bug: Something broke',
    body: 'Here are the details...',
    labels: ['bug'],
    assignees: ['jordanpartridge'],
);

echo $issue->number;
echo $issue->html_url;

$bugs = Github::issues()->forRepo(
    owner: 'jordanpartridge',
    repo: 'github-client',
    state: State::OPEN,
    labels: 'bug',
)->dto();

$comments = Github::issues()->comments('jordanpartridge', 'github-client', $issue->number);

Github::issues()->addComment('jordanpartridge', 'github-client', $issue->number, 'Fixed in the latest release.');

Github::issues()->close('jordanpartridge', 'github-client', $issue->number);

forRepo() and all() return a Saloon response. Call dto() for IssueDTO objects. allForRepo() paginates the same way allWithPagination() does and returns the array itself.

GitHub's issue list includes pull requests. List endpoints skip items that have a pull_request key.

labels on forRepo() is a comma-separated string. labels on create() is a list of label names.

Pull requests

use JordanPartridge\GithubClient\Enums\MergeMethod;

$pr = Github::pullRequests()->create(
    owner: 'jordanpartridge',
    repo: 'github-client',
    title: 'Add a feature',
    head: 'feature-branch',
    base: 'main',
    body: 'This pull request adds the feature.',
    draft: false,
);

$merged = Github::pullRequests()->merge(
    owner: 'jordanpartridge',
    repo: 'github-client',
    number: $pr->number,
    mergeMethod: MergeMethod::Squash,
);

merge() returns true when GitHub reports the pull request as merged.

pullRequests()->all($owner, $repo) returns PullRequestDTO objects. Pass GitHub query parameters in the third argument, for example ['state' => 'open']. files($owner, $repo, $number) returns the changed files. There is no commits() method on the pull request resource. Use commits() for commit history.

Commits, releases, files, Actions

$commits = Github::commits()->all('laravel/framework');

$commit = Github::commits()->get(
    'laravel/framework',
    '0123456789abcdef0123456789abcdef01234567',
);

echo $commit->sha;
echo $commit->commit->message;

commits()->get() and files()->all() require a 40 character hexadecimal SHA.

$latest = Github::releases()->latest('laravel', 'framework');

echo $latest->tag_name;

releases() can list releases and fetch one by id. It cannot create a release.

$tree = Github::files()->all(
    'laravel/framework',
    '0123456789abcdef0123456789abcdef01234567',
);

files()->all() returns the Saloon response for that commit's tree. There is no get() or contents() method.

$workflows = Github::actions()->listWorkflows('laravel', 'framework');

$runs = Github::actions()->getWorkflowRuns('laravel', 'framework', $workflowId);

Github::actions()->triggerWorkflow('laravel', 'framework', $workflowId, [
    'ref' => 'main',
]);

ref is required. listWorkflows() and getWorkflowRuns() return Saloon responses. Owner and repo are separate arguments.

Rate limit

$limits = Github::getRateLimitStatus();
$core = Github::getRateLimitForResource('core');

The package does not retry rate-limited calls. A rate limit response throws RateLimitException.

Resources

Call Methods Returns
repos() all, allWithPagination, get(Repo), delete(Repo), search all is a Saloon response. allWithPagination and get return RepoData. search returns SearchRepositoriesData.
issues() all, forRepo, allForRepo, get, create, update, close, reopen, comments, addComment, getComment, updateComment, deleteComment all and forRepo are Saloon responses. get, create, update, close, and reopen return IssueDTO.
pullRequests() all, get, create, update, merge, files, diff, comments, summaries, detail all and get return pull request DTOs. merge returns a boolean.
commits() all(owner/repo), get(owner/repo, sha) CommitData
files() all(owner/repo, sha) Saloon response
releases() all, get, latest ReleaseData
actions() listWorkflows, getWorkflowRuns, triggerWorkflow Saloon response
comments() forPullRequest, byAuthor, byAuthorType, bySeverity, forFile Pull request review comments. Pass 'repository' => 'owner/repo' in the filters. pullRequests()->comments($owner, $repo, $number) is the direct call.
installations() list, listAll, get, createAccessToken GitHub App installation data. Requires app authentication.

Github::getRepo('owner/repo') and Github::deleteRepo('owner/repo') validate the name and call the repository resource.

Dependency injection

The container binds JordanPartridge\GithubClient\Github. There is no connector interface.

use JordanPartridge\GithubClient\Github;

class RepoListing
{
    public function __construct(
        private readonly Github $github,
    ) {}

    public function handle(): array
    {
        return $this->github->repos()->all()->dto();
    }
}

OAuth

Set these before resolving the GithubOAuth facade:

GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
GITHUB_REDIRECT_URL=https://example.com/auth/github/callback
use JordanPartridge\GithubClient\Connectors\GithubConnector;
use JordanPartridge\GithubClient\Facades\GithubOAuth;

$state = bin2hex(random_bytes(16));

return redirect(GithubOAuth::getAuthorizationUrl(['repo', 'user'], $state));

Store $state in the session and compare it on the callback. The package does not store or check state. If you omit it, getAuthorizationUrl() adds a random value and discards it.

$token = GithubOAuth::getAccessToken($request->string('code'));

$github = new GithubConnector($token);
$repos = $github->send(
    new \JordanPartridge\GithubClient\Requests\Repos\Index(),
);

To keep using the facade-style resources with that token, build a client around the connector:

$github = new \JordanPartridge\GithubClient\Github(new GithubConnector($token));

$repos = $github->repos()->all()->dto();

Missing OAuth config throws AuthenticationException from getAuthorizationUrl() and getAccessToken().

GitHub App

These helpers are on the default branch. They are not in Packagist v3.0.0.

GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"

GITHUB_APP_PRIVATE_KEY_PATH is used when GITHUB_APP_PRIVATE_KEY is empty. The path may be absolute or relative to the Laravel base path.

use JordanPartridge\GithubClient\Facades\Github;

$github = Github::forInstallation(12345678);

$repos = $github->repos()->all()->dto();

Github::withApp($appId, $privateKey, $installationId) does the same without reading config. installations() lists installations and creates installation tokens. Those calls need a JWT, which forInstallation() and withApp() set up. Pass the installation id to forInstallation().

Artisan

php artisan github-client test
php artisan github-client repo --repo=laravel/framework
php artisan github-client commits --repo=laravel/framework --limit=10

test calls repos()->all(per_page: 1). repo prints fields from RepoData. commits prints the SHA, author, date, and message.

Testing

Mock the connector with Saloon. IssueDTO reads a full issue payload, including user. A short fixture will throw while mapping. tests/TestCase.php builds a complete one, and tests/DocumentationExamplesTest.php runs the calls from this README.

use JordanPartridge\GithubClient\Facades\Github;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;

it('creates an issue', function () {
    Github::connector()->withMockClient(new MockClient([
        '*' => MockResponse::make([
            'id' => 1,
            'number' => 42,
            'title' => 'Test Issue',
            'body' => 'Details',
            'state' => 'open',
            'comments' => 0,
            'html_url' => 'https://github.com/owner/repo/issues/42',
            'user' => [
                'login' => 'owner',
                'id' => 1,
                'node_id' => 'MDQ6VXNlcjE=',
                'avatar_url' => 'https://github.com/owner.png',
                'url' => 'https://api.github.com/users/owner',
                'html_url' => 'https://github.com/owner',
                'followers_url' => 'https://api.github.com/users/owner/followers',
                'following_url' => 'https://api.github.com/users/owner/following{/other_user}',
                'gists_url' => 'https://api.github.com/users/owner/gists{/gist_id}',
                'starred_url' => 'https://api.github.com/users/owner/starred{/owner}{/repo}',
                'subscriptions_url' => 'https://api.github.com/users/owner/subscriptions',
                'organizations_url' => 'https://api.github.com/users/owner/orgs',
                'repos_url' => 'https://api.github.com/users/owner/repos',
                'events_url' => 'https://api.github.com/users/owner/events{/privacy}',
                'received_events_url' => 'https://api.github.com/users/owner/received_events',
                'type' => 'User',
            ],
            'created_at' => '2024-01-01T00:00:00Z',
            'updated_at' => '2024-01-01T00:00:00Z',
            'closed_at' => null,
        ], 201),
    ]));

    $issue = Github::issues()->create('owner', 'repo', 'Test Issue');

    expect($issue->number)->toBe(42);
    expect($issue->title)->toBe('Test Issue');
});

Run the package tests with:

composer test

composer analyse runs PHPStan. composer format runs Pint.

Errors

Failed responses are mapped in GithubConnector:

Status Exception
401 AuthenticationException
403 rate limit RateLimitException
403 permissions ApiException
404 ResourceNotFoundException
422 ValidationException
429 RateLimitException
500, 502, 503, 504 NetworkException

Requirements

  • PHP 8.2, 8.3, or 8.4
  • Laravel 11, 12, or 13 (illuminate/contracts)
  • saloonphp/saloon ^4.3
  • firebase/php-jwt ^7.0, used by GitHub App authentication
  • spatie/laravel-package-tools ^1.16 or ^2.0

Contributing

Pull requests are welcome. Run composer test before opening one.

License

MIT. See LICENSE.md.

Built with Saloon by Jordan Partridge.

About

A Github client that plays nice with Laravel

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages