NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Git for Laravel

The Git facade

Everything starts at the Git facade. Git::github() (or gitlab(), bitbucket(), provider()) hands you an authenticated driver; ->repo('owner/name') scopes it to one repository, and ->pullRequest($number) to one pull request of it. With a token in config you don’t pass a credential at all; an explicit credential always overrides config:

use RoundlyConsulting\Git\Enums\MergeMethod;
use RoundlyConsulting\Git\Facades\Git;

$github = Git::github();                       // a driver: account-wide reads and writes
$github->repositories();                       // Page<Repository>
$github->createRepository($newRepository);     // Repository

$repo = $github->repo('acme/app');             // a handle on one repository
$repo->pullRequests('open');                   // Page<PullRequest>
$repo->commits()->branch('main')->lazy();      // LazyCollection<int, Commit>
$repo->contents('README.md');                  // FileContent (decoded)
$repo->compare('main', 'feature/ci');          // Comparison
$repo->webhooks()->register();                 // Webhook

$pr = $repo->pullRequest(12);                  // a handle on one pull request
$pr->approve('LGTM');
$pr->merge(MergeMethod::Squash, sha: $pr->get()->raw()['head']['sha'] ?? null);

Git::githubApp()->installations()->find($id);  // app-JWT lookups
Git::capabilities('bitbucket');                // no authentication needed
Git::credentials('github');                    // ?Credentials — what github() would use
Git::verifyWebhook('github', $request);        // bool

The API at a glance

WhereMethods
Git::github(?Credentials), githubApp(?GithubApp), gitlab(?Credentials), bitbucket(?Credentials), provider(name, ?Credentials), capabilities(name), credentials(name), verifyWebhook(name, Request), fake(), macro()
Git::github()repo(path|Repository), installations(), user(), repositories(), allRepositories(), searchRepositories(), createRepository(), installationRepositories(), allInstallationRepositories(), batch(), rateLimit(), supports() / supportsAll() / supportsAny() / capabilities() / features() / featureMatrix() / featureInfo(), authenticate(), isAuthenticated()
->repo('acme/app')get(), path(), branches(), createBranch(), commit($sha), commits(), pullRequests(), pullRequest($n), createPullRequest(), issues(), issue($n), comment(), tags(), createTag(), releases(), release(), createRelease(), contents(), createFile(), updateFile(), compare(), contributors(), languages(), webhooks(), cloneUrl()
->pullRequest(12)get(), number(), merge(), approve(), review(), reviews(), close(), comment()
Git::githubApp()->installations()all(), find($id), forOrganization($org), forUser($login), installUrl(?$state)
->repo(…)->webhooks()register(), all(), registered(), delete(), deleteByUrl()

Git::credentials() returns the credential a driver gets when you pass none — the configured GitHub App installation first, then the static token, else null — so you can hand the same credential to something outside the package, such as a clone URL. Git::verifyWebhook() runs the package’s own signature check for a route you own (see Webhooks).

Handles refuse to leave their scope

Every path a handle takes ends up inside a forge URL, so the handles check it first and throw OutOfScopeException (an InvalidArgumentException) instead of addressing some other resource:

use RoundlyConsulting\Git\Exceptions\OutOfScopeException;

Git::github()->repo('acme/app/../billing');          // OutOfScopeException
Git::github()->repo($gitlabRepository);              // OutOfScopeException: belongs to GitLab
Git::github()->repo('acme/app')->contents('../.env'); // OutOfScopeException
Git::github()->repo('acme/app')->contents('%2e%2e/%2e%2e/x');   // OutOfScopeException — decoded first
Git::github()->repo('acme/app')->webhooks()->delete('1/../..'); // OutOfScopeException
Git::github()->repo('acme/app')->pullRequest(0);     // OutOfScopeException
  • repo() refuses an empty path, an empty, . or .. segment, and ?, #, \, a NUL byte or whitespace — and a Repository object that belongs to another provider (a GitLab repository on Git::github()).
  • contents(), createFile() and updateFile() refuse file paths with ., .. or empty segments, ?, #, \ or a NUL byte; commit(), release() and compare() refuse such refs, as does any other ref that lands in a URL path (GitHub’s createBranch() base ref and createTag() ref).
  • Every value is checked as given and after each percent-decoding, because the HTTP stack decodes %2e and collapses dot segments before a request leaves: %2e%2e/victim, %252e%252e/victim and ..%2Fvictim are refused exactly like ../victim. What passes is percent-encoded segment by segment, so the forge receives the literal name you gave (docs/a b.md → docs/a%20b.md, 100%.md → 100%25.md).
  • pullRequest() refuses a number below 1; installations()->find() a non-numeric id; forOrganization() / forUser() anything but a single segment.
  • webhooks()->delete($id) — and git:webhook --delete= — takes only the id shape the forge issues: numeric on GitHub and GitLab, a braced {uuid} on Bitbucket, because the id lands in a DELETE URL.

The flat driver methods (Git::github()->contents('acme/app', …)) and batch() build their URLs through the same checks, so they refuse the same values.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.