PHPackages                             jkudish/graft - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Testing &amp; Quality](/categories/testing)
4. /
5. jkudish/graft

ActiveLibrary[Testing &amp; Quality](/categories/testing)

jkudish/graft
=============

Git manager and platform provider for Laravel — facades, typed DTOs, test fakes, and scoped repositories.

v0.3.0(1mo ago)2287↓68.6%MITPHPPHP ^8.2CI passing

Since May 7Pushed 1mo agoCompare

[ Source](https://github.com/jkudish/graft)[ Packagist](https://packagist.org/packages/jkudish/graft)[ Docs](https://github.com/jkudish/graft)[ RSS](/packages/jkudish-graft/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (9)Versions (7)Used By (0)

 [![Graft — Git and GitHub for Laravel](art/banner.png)](art/banner.png)

Graft
=====

[](#graft)

 **Branch, commit, and ship from your Laravel app — without ever shelling out by hand or hand-rolling the GitHub API.**

 [![Latest Version](https://camo.githubusercontent.com/6364603ecc0de091451b65449ca0b0b03168d6200203bd1f7060c0fa985f9004/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6a6b75646973682f67726166742e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/jkudish/graft) [![Tests](https://camo.githubusercontent.com/ae7445d6faaafdfacda1d2c34603408e689ea3c72c45373c2fcae5eae94578f1/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6a6b75646973682f67726166742f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/jkudish/graft/actions/workflows/tests.yml) [![Code Quality](https://camo.githubusercontent.com/73d5b10d47c1531f30e2588d4dc3abb88e7bba3d602c4a220241c48b0439d9ad/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6a6b75646973682f67726166742f636f64652d7175616c6974792e796d6c3f6272616e63683d6d61696e266c6162656c3d636f64652532307175616c697479267374796c653d666c61742d737175617265)](https://github.com/jkudish/graft/actions/workflows/code-quality.yml) [![Total Downloads](https://camo.githubusercontent.com/cdaa245c0d094bf3bee89b97a6c5aa4d5ed218231e07d1351f7c1e1ecbb75ac3/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6a6b75646973682f67726166742e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/jkudish/graft) [![License](https://camo.githubusercontent.com/d04b125cd7086a728a5740cfd10b6442c8bb970fd14fb028d1a8ef8e75a7d492/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6a6b75646973682f67726166742e7376673f7374796c653d666c61742d737175617265)](LICENSE) [![PHP Version](https://camo.githubusercontent.com/f7c44581a7c058ed212100b5004105f5a7473f4bd7e4f95da79536323263aa48/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6a6b75646973682f67726166742e7376673f7374796c653d666c61742d737175617265)](composer.json)

---

Graft is the missing git-and-platform layer for Laravel. It puts everything you'd reach for `exec('git ...')` or a hand-rolled GitHub HTTP client to do behind two clean facades — `Git` and `GitHub` — and returns typed, readonly DTOs instead of raw output or arrays.

It was built for the kind of app that needs to *do real work* in real repos: tools that orchestrate AI coding agents, dashboards that open and merge PRs on your behalf, internal automation that branches, commits, and ships. It scales from "create a tag" to "spin up a worktree, run a series of changes, open a PR, request review, watch CI, merge, and clean up" — without you ever leaving Laravel idioms.

Three things make it pleasant:

- **`Git::repo($path)`** scopes both git and platform calls to a single repository — no more threading `$repoPath` through every method, and `owner/repo` is auto-detected from the origin remote.
- **Active objects.** `$pr->merge()`, `$pr->requestReview([...])`, `$issue->close()` — DTOs returned from the platform carry their actions with them.
- **Tests that read like specs.** `Git::fake()` and `GitHub::fake()` return recording fakes with semantic assertions (`assertBranchCreated`, `assertPrCreated`, `assertReviewRequested`) — no Mockery boilerplate, no string-matching command lines.

```
use Graft\Facades\Git;

$repo = Git::repo('/path/to/project');

$repo->checkout('feature/payments', create: true);
$repo->add('.');
$repo->commit('Add Stripe webhook handler');
$repo->push(setUpstream: true);

$pr = $repo->createPullRequest(
    title: 'Add Stripe webhook handler',
    body: 'Closes #142',
    head: 'feature/payments',
    base: 'main',
);

$pr->requestReview(['teammate']);
$pr->addLabels(['enhancement']);
```

What's in the box
-----------------

[](#whats-in-the-box)

- **`Git` facade** — branches, commits, index, remotes, merge, rebase, cherry-pick, tags, stash, worktrees, blame, clean.
- **`GitHub` facade** — pull requests, issues, reviews, comments, CI status, labels, repository info.
- **Scoped repository** — `Git::repo($path)` binds both surfaces to a single repo and auto-detects `owner/repo` from the origin remote.
- **Typed DTOs** — `Branch`, `Commit`, `Status`, `MergeResult`, `Stash`, `Worktree`, `PullRequest`, `Issue`, `Review`, `CheckRun`, `CiStatus`, and more — all readonly, all with named properties.
- **Recording fakes** — `Git::fake()` and `GitHub::fake()` swap the real implementations for in-memory recorders with semantic assertions and configurable return values / exceptions.
- **Errors with context** — `MergeConflictException` exposes the conflicting files; `PlatformException` exposes the status code and the response body.

Requirements
------------

[](#requirements)

- PHP 8.2+
- Laravel 11, 12, or 13
- `git` binary on `PATH`

Installation
------------

[](#installation)

```
composer require jkudish/graft
```

Optionally publish the config:

```
php artisan vendor:publish --tag=graft-config
```

Set your GitHub token in `.env`:

```
GITHUB_TOKEN=ghp_your_token_here
```

That single token powers both the GitHub API client *and* git's HTTPS auth — see [Authentication](#authentication) for the details, including the `config:cache` gotcha that bites every Forge deploy.

Table of Contents
-----------------

[](#table-of-contents)

- [Scoped Repository](#scoped-repository) — the recommended entry point
- [Git Facade](#git-facade) — local repository operations
- [GitHub Facade](#github-facade) — platform operations
- [AI Tools](#ai-tools-laravel-ai-sdk) — ready-to-use tools for the Laravel AI SDK
- [Active Objects](#active-objects) — methods on PRs and Issues
- [Authentication](#authentication) — one token for the API and git subprocesses
- [Testing](#testing) — `Git::fake()` and `GitHub::fake()`
- [Error Handling](#error-handling)
- [Configuration](#configuration)
- [DTOs](#data-transfer-objects)

Scoped Repository
-----------------

[](#scoped-repository)

`Git::repo($path)` returns a `ScopedRepository` that binds both git and platform operations to a single path. It's the most ergonomic way to use Graft.

```
$repo = Git::repo('/path/to/project');

$repo->currentBranch();
$repo->checkout('feature/x', create: true);
$repo->add('.');
$repo->commit('Changes');
$repo->push(setUpstream: true);

$pr     = $repo->createPullRequest(title: 'Feature X', body: '...', head: 'feature/x', base: 'main');
$issues = $repo->listIssues();
$ci     = $repo->getCiStatus('feature/x');
```

The scoped repository detects `owner/repo` from the origin remote URL (HTTPS or SSH) and resolves the configured platform provider automatically.

Git Facade
----------

[](#git-facade)

The `Git` facade proxies all methods on `GitManager`. Each method takes `$repoPath` as its first argument — or use `Git::repo($path)` to drop it entirely.

### Branches

[](#branches)

```
Git::branches($path);              // Collection
Git::currentBranch($path);         // "main"
Git::branchExists($path, 'dev');

Git::createBranch($path, 'feature/x', 'main');
Git::checkout($path, 'feature/x');
Git::deleteBranch($path, 'feature/x', force: true);
```

### Commits and Index

[](#commits-and-index)

```
Git::add($path, ['src/File.php']);
Git::add($path);                                         // stage everything

$commit = Git::commit($path, 'Fix the thing');
// Commit { hash, shortHash, message, author, email, date, parents }

Git::log($path, limit: 5);                               // Collection
Git::show($path);                                        // HEAD commit
Git::status($path);                                      // Status { staged, unstaged, untracked }
Git::diff($path, staged: true);                          // string
```

### Remotes and Syncing

[](#remotes-and-syncing)

```
Git::fetch($path, prune: true);
Git::pull($path, 'origin', 'main');
Git::push($path, 'origin', 'main', setUpstream: true);

Git::remotes($path);                                     // Collection
Git::addRemote($path, 'upstream', 'https://github.com/org/repo.git');
Git::removeRemote($path, 'upstream');
```

### Merge, Rebase, Cherry-pick

[](#merge-rebase-cherry-pick)

```
$result = Git::merge($path, 'feature/x', noFf: true);
// MergeResult { success, message, conflicts }

Git::rebase($path, 'main');
Git::cherryPick($path, ['abc123', 'def456']);

Git::mergeAbort($path);
Git::rebaseAbort($path);
Git::cherryPickAbort($path);
```

### Tags, Stash, Worktrees

[](#tags-stash-worktrees)

```
Git::tags($path);                                        // Collection
Git::createTag($path, 'v1.0.0', message: 'Release 1.0');
Git::deleteTag($path, 'v1.0.0');

Git::stash($path, message: 'WIP', includeUntracked: true);
Git::stashPop($path);
Git::stashList($path);                                   // Collection

$wt = Git::addWorktree($path, '/tmp/worktree', 'feature/x', createBranch: true);
Git::listWorktrees($path);                               // Collection
Git::removeWorktree($path, '/tmp/worktree', force: true);
```

### Repository and Config

[](#repository-and-config)

```
Git::init('/path/to/new-repo');
Git::clone('https://github.com/org/repo.git', '/path/to/dest', branch: 'main');
Git::isRepository('/some/path');

Git::getConfig($path, 'user.name');
Git::setConfig($path, 'user.name', 'Graft');

Git::blame($path, 'src/File.php');                       // Collection
Git::clean($path, directories: true);
```

GitHub Facade
-------------

[](#github-facade)

The `GitHub` facade works with any repository by passing `owner/repo` directly.

### Pull Requests

[](#pull-requests)

```
$pr  = GitHub::createPullRequest('owner/repo', 'Title', 'Body', 'feature', 'main', draft: true);
$pr  = GitHub::getPullRequest('owner/repo', 42);
$prs = GitHub::listPullRequests('owner/repo', state: 'open');

GitHub::updatePullRequest('owner/repo', 42, ['title' => 'New title']);
GitHub::mergePullRequest('owner/repo', 42, method: 'squash');
GitHub::closePullRequest('owner/repo', 42);
```

### Issues

[](#issues)

```
$issue = GitHub::createIssue('owner/repo', 'Bug', 'Details', labels: ['bug']);
$issue = GitHub::getIssue('owner/repo', 10);

GitHub::listIssues('owner/repo', state: 'open');
GitHub::updateIssue('owner/repo', 10, ['state' => 'closed']);
```

### Reviews, Comments, CI, Labels

[](#reviews-comments-ci-labels)

```
GitHub::requestReview('owner/repo', 42, ['reviewer1']);
GitHub::listReviews('owner/repo', 42);                   // Collection

GitHub::addComment('owner/repo', 42, 'Looks good!');
GitHub::addReviewComment('owner/repo', 42, 'Nit', 'abc123', 'src/File.php', 15);

GitHub::getCiStatus('owner/repo', 'abc123');             // CiStatus { state, checkRuns }
GitHub::listCheckRuns('owner/repo', 'abc123');

GitHub::addLabels('owner/repo', 42, ['ready-for-review']);
GitHub::removeLabel('owner/repo', 42, 'wip');
```

### Repository Info

[](#repository-info)

```
GitHub::getRepository('owner/repo');
// Repository { name, fullName, description, defaultBranch, private, url }
```

AI Tools (Laravel AI SDK)
-------------------------

[](#ai-tools-laravel-ai-sdk)

Graft ships nine ready-to-use tools for the [Laravel AI SDK](https://github.com/laravel/ai). Each tool implements both `Laravel\Ai\Contracts\Tool` (so the SDK can call it) and `Graft\Ai\Contracts\IdentifiableTool` (so you can discover, route, or expose it via MCP using a stable `category:action` ID).

Tool`toolId()`What it does`GitLogTool``graft:git:log`Recent commit history for a repo`GitStatusTool``graft:git:status`Working tree status (staged / unstaged / untracked)`GitDiffTool``graft:git:diff`Diff for the working tree or a single file`GitBranchesTool``graft:git:branches`List branches and the current branch`GitHubListPrsTool``graft:github:list-prs`List pull requests for `owner/repo``GitHubGetIssueTool``graft:github:get-issue`Fetch a single issue by number`GitHubCreateIssueTool``graft:github:create-issue`Create a new issue`GitHubListIssuesTool``graft:github:list-issues`List issues for `owner/repo``GitHubPrReviewTool``graft:github:pr-review`Add a review (approve, request changes, comment)Register them with an Agent like any other Laravel AI tool:

```
use Laravel\Ai\Agent;
use Laravel\Ai\Promptable;
use Graft\Ai\Tools\GitLogTool;
use Graft\Ai\Tools\GitStatusTool;
use Graft\Ai\Tools\GitHubListPrsTool;

class ReleaseManager extends Agent
{
    use Promptable;

    public function instructions(): string
    {
        return 'You help draft release notes from git history and open PRs.';
    }

    public function tools(): array
    {
        return [
            GitLogTool::class,
            GitStatusTool::class,
            GitHubListPrsTool::class,
        ];
    }
}
```

Or use them directly without an agent — they're plain classes:

```
use Graft\Ai\Tools\GitLogTool;
use Laravel\Ai\Tools\Request;

$tool = new GitLogTool;
$json = $tool->handle(new Request(['repo_path' => '/path/to/repo', 'limit' => 5]));
```

**Requires** the optional `laravel/ai` dependency:

```
composer require laravel/ai
```

The tools call into the `Git` and `GitHub` facades under the hood, so `Git::fake()` and `GitHub::fake()` work exactly the same way for testing them.

Active Objects
--------------

[](#active-objects)

`PullRequest` and `Issue` DTOs returned from the platform provider carry a reference back to the provider so you can act on them directly.

```
$pr = GitHub::getPullRequest('owner/repo', 42);

$pr->merge(method: 'squash');
$pr->close();
$pr->update(['title' => 'Updated']);
$pr->requestReview(['teammate']);
$pr->addComment('Ship it!');
$pr->addReviewComment('Fix this', 'abc123', 'src/File.php', 10);
$pr->getCiStatus();
$pr->addLabels(['approved']);

$issue = GitHub::getIssue('owner/repo', 10);

$issue->close();
$issue->update(['title' => 'Updated']);
$issue->addComment('Fixed in #42');
$issue->addLabels(['resolved']);
```

Authentication
--------------

[](#authentication)

Graft uses a single token — `GITHUB_TOKEN` — for two distinct things:

1. **The GitHub API client.** `Bearer` auth on every HTTP call — no surprises.
2. **`git` subprocesses over HTTPS.** Anything that reaches a remote (`Git::clone`, `Git::fetch`, `Git::pull`, `Git::push`, `Git::addWorktree` on a private parent) needs the same token to authenticate.

Graft handles both for you. When you call `Git::init`, `Git::clone`, or `Git::addWorktree`, it writes a host-scoped credential helper to the repo's `.git/config` so subsequent git operations authenticate without further setup. For `Git::clone` specifically, it also injects the helper as ephemeral git config (via the `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_*`/`GIT_CONFIG_VALUE_*` env vars git supports since 2.31) so the clone *itself* can authenticate — without that bootstrap, the persisted helper would only take effect after the clone has already needed credentials.

### The two modes

[](#the-two-modes)

```
'git_credentials' => [
    'enabled'  => env('GRAFT_GIT_CREDENTIALS_ENABLED', true),
    'mode'     => env('GRAFT_GIT_CREDENTIALS_MODE', 'baked'),
    'username' => env('GRAFT_GIT_CREDENTIALS_USERNAME', 'x-access-token'),
    'host'     => env('GRAFT_GIT_CREDENTIALS_HOST'), // null = derive from base_url
],
```

ModeWhat lands in `.git/config`Token at rest?Best for`baked`The literal token, inside a per-host credential helper snippetYesProduction servers, especially behind `config:cache` (default)`env`A `${GRAFT_GITHUB_TOKEN}` placeholder; Graft injects the var into every git subprocess's envNoEnvironments where you don't want secrets in `.git/config`> **About `baked`-mode threat surface.** Token-at-rest in `.git/config` is roughly the same threat surface as `.env`, with one wrinkle: `.env` is typically `640` (or stricter) on Forge-style deploys, while `.git/config` is whatever your umask produces (often `644`, sometimes more permissive). On shared hosts where local users matter, lock down `.git/config` permissions or use `mode=env`.

### Hosts and GitHub Enterprise

[](#hosts-and-github-enterprise)

The credential host is auto-derived from `base_url` by stripping a leading `api.` — that covers github.com (`api.github.com → github.com`) and the canonical GHE pattern (`https://github.example.com/api/v3` → `https://github.example.com`). Self-hosted setups that don't follow either pattern should set `GRAFT_GIT_CREDENTIALS_HOST` explicitly.

If you've already configured a per-host credential helper at the same key (e.g. via `gh auth setup-git`), Graft's installation will overwrite it on the next `init` / `clone` / `addWorktree`. Set `enabled=false` to keep your existing setup.

### The `config:cache` gotcha

[](#the-configcache-gotcha)

This is the bug that motivated the unified token feature, and it's worth knowing about even if you never look at the implementation.

Laravel's `config:cache` skips `LoadEnvironmentVariables` on subsequent boots. After it runs, your `.env` values still reach `config()` (because they were captured when the cache was built), but `$_ENV` and `$_SERVER` are *empty*. Symfony Process inherits its child env from `$_ENV + $_SERVER` (not `getenv_all`), so a credential helper that does `password=$GITHUB_TOKEN` finds nothing and `git fetch` fails with `Authentication failed`.

Graft sidesteps this in both modes:

- `baked` mode never relies on env at all — the token is in `.git/config`.
- `env` mode passes `GRAFT_GITHUB_TOKEN` to `Process` via the explicit `$env` array, which Symfony forwards to the child regardless of `$_ENV` state.

If you've ever shipped an app to Forge with a hand-rolled credential helper script, this is the failure mode you hit. The default `baked` mode makes it impossible.

### Opting out

[](#opting-out)

Set `GRAFT_GIT_CREDENTIALS_ENABLED=false` to skip credential installation entirely. Graft's other behavior is unchanged. You're then responsible for git auth — typically a global `gh auth setup-git`, a system credential helper, or your own per-repo helper.

Testing
-------

[](#testing)

Both facades have `fake()` methods that swap in a recording fake with semantic assertions.

```
use Graft\Facades\Git;
use Graft\Facades\GitHub;

it('creates a feature branch, opens a PR, and requests review', function () {
    $git = Git::fake();
    $github = GitHub::fake();

    // ...your code under test...

    $git->assertBranchCreated('feature/x');
    $git->assertCommitted('Add feature');
    $git->assertPushed('feature/x');

    $github->assertPrCreated('Add feature');
    $github->assertReviewRequested(['teammate']);
    $github->assertLabelsAdded(['enhancement']);
});
```

### Git Assertions

[](#git-assertions)

```
// Generic
$fake->assertCalled('commit');
$fake->assertCalled('commit', fn ($args) => str_contains($args[1], 'fix'));
$fake->assertNotCalled('push');
$fake->assertCalledTimes('fetch', 2);

// Semantic
$fake->assertBranchCreated('name');
$fake->assertCheckedOut('branch');
$fake->assertCommitted('message substring');
$fake->assertPushed('branch');
$fake->assertPulled();
$fake->assertFetched();
$fake->assertMerged('branch');
$fake->assertTagCreated('v1.0.0');
$fake->assertCloned('https://...');
$fake->assertInitialized('/path');
$fake->assertWorktreeAdded('/path');
$fake->assertWorktreeRemoved('/path');
$fake->assertStashed();

// Negative
$fake->assertNothingPushed();
$fake->assertNothingCommitted();
$fake->assertNothingCalled();
```

### GitHub Assertions

[](#github-assertions)

```
$fake->assertPrCreated('title');
$fake->assertPrMerged(42);
$fake->assertPrClosed(42);
$fake->assertIssueCreated('title');
$fake->assertIssueClosed(10);
$fake->assertCommentAdded('body substring');
$fake->assertLabelsAdded(['label1']);
$fake->assertReviewRequested(['reviewer1']);
$fake->assertNothingCalled();
```

### Configuring Return Values and Errors

[](#configuring-return-values-and-errors)

```
$fake = Git::fake();
$fake->shouldReturn('currentBranch', 'develop');
$fake->shouldReturn('status', new Status(staged: ['file.php'], unstaged: [], untracked: []));
$fake->shouldThrow('push', new ProcessException('Remote rejected'));
```

Error Handling
--------------

[](#error-handling)

```
RuntimeException
├── GitException                 // base for all git errors (command + stderr context)
│   ├── ProcessException         // git process failed
│   ├── BranchException          // branch operation failed
│   ├── MergeConflictException   // exposes conflicts: list
│   ├── WorktreeException
│   └── TagException
└── PlatformException            // exposes statusCode + response

```

```
use Graft\Exceptions\MergeConflictException;
use Graft\Exceptions\PlatformException;

try {
    Git::merge($path, 'feature/x');
} catch (MergeConflictException $e) {
    $e->conflicts;                // list
    Git::mergeAbort($path);
}

try {
    GitHub::mergePullRequest('owner/repo', 42);
} catch (PlatformException $e) {
    $e->statusCode;               // 409
    $e->response;                 // ['message' => 'Pull request is not mergeable']
}
```

Configuration
-------------

[](#configuration)

Published to `config/graft.php`:

```
return [
    'git_binary' => env('GRAFT_GIT_BINARY', 'git'),
    'timeout'    => env('GRAFT_TIMEOUT', 60),

    'platform' => [
        'default'   => env('GRAFT_PLATFORM', 'github'),
        'providers' => [
            'github' => [
                'token'    => env('GITHUB_TOKEN'),
                'base_url' => env('GITHUB_API_URL', 'https://api.github.com'),

                'git_credentials' => [
                    'enabled'  => env('GRAFT_GIT_CREDENTIALS_ENABLED', true),
                    'mode'     => env('GRAFT_GIT_CREDENTIALS_MODE', 'baked'),
                    'username' => env('GRAFT_GIT_CREDENTIALS_USERNAME', 'x-access-token'),
                    'host'     => env('GRAFT_GIT_CREDENTIALS_HOST'),
                ],
            ],
        ],
    ],
];
```

VariableDefaultDescription`GITHUB_TOKEN`*(required)*GitHub personal access token (used for both API and git HTTPS auth)`GRAFT_GIT_BINARY``git`Path to the git binary`GRAFT_TIMEOUT``60`Timeout in seconds for git commands`GRAFT_PLATFORM``github`Default platform provider`GITHUB_API_URL``https://api.github.com`GitHub API base URL (for GitHub Enterprise)`GRAFT_GIT_CREDENTIALS_ENABLED``true`Auto-install a host-scoped credential helper on init/clone/worktree`GRAFT_GIT_CREDENTIALS_MODE``baked``baked` (token in .git/config) or `env` (token via `GRAFT_GITHUB_TOKEN`)`GRAFT_GIT_CREDENTIALS_USERNAME``x-access-token`Username sent to the helper (PATs ignore it; GitHub Apps need this)`GRAFT_GIT_CREDENTIALS_HOST`*(derived)*Override the credential host (e.g. `https://github.example.com`)Data Transfer Objects
---------------------

[](#data-transfer-objects)

All DTOs are readonly classes with typed properties.

**Git:** `Branch`, `Commit`, `Status`, `Remote`, `MergeResult`, `Stash`, `Worktree`, `Blame`

**Platform:** `PullRequest` (active), `Issue` (active), `Comment`, `Review`, `CheckRun`, `CiStatus`, `Repository`

Contributing
------------

[](#contributing)

PRs welcome. Run the suite before pushing:

```
composer test         # unit + feature
composer test:all     # includes integration (requires real git)
composer phpstan      # level 8
composer lint         # Pint
```

License
-------

[](#license)

Graft is open-sourced software licensed under the [MIT license](LICENSE).

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance94

Actively maintained with recent releases

Popularity20

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity40

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

5

Last Release

31d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/ca20d16a7871a44d060a80ad572c90be25dd5b73212b0a839fb0474e623ee357?d=identicon)[jkudish](/maintainers/jkudish)

---

Top Contributors

[![jkudish](https://avatars.githubusercontent.com/u/260253?v=4)](https://github.com/jkudish "jkudish (17 commits)")

---

Tags

testinglaravelgithubgitfacadedtoscoped-repositoryplatform-provider

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/jkudish-graft/health.svg)

```
[![Health](https://phpackages.com/badges/jkudish-graft/health.svg)](https://phpackages.com/packages/jkudish-graft)
```

###  Alternatives

[laravel/horizon

Dashboard and code-driven configuration for Laravel queues.

4.1k91.3M277](/packages/laravel-horizon)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[spatie/laravel-responsecache

Speed up a Laravel application by caching the entire response

2.8k8.7M64](/packages/spatie-laravel-responsecache)[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[spatie/laravel-export

Create a static site bundle from a Laravel app

670139.5k6](/packages/spatie-laravel-export)[pressbooks/pressbooks

Pressbooks is an open source book publishing tool built on a WordPress multisite platform. Pressbooks outputs books in multiple formats, including PDF, EPUB, web, and a variety of XML flavours, using a theming/templating system, driven by CSS.

45344.0k1](/packages/pressbooks-pressbooks)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
