Know When to Drop Your Forked Composer Packages

/ Content

Two weeks into upgrading to Laravel 13, I was almost done. Most packages had updated within the first few days. Some took a week. By day ten, there was one holdout: Bugsnag. Their bugsnag-laravel package needed a straightforward compatibility update and the PR existed, but it hadn't been merged. I didn't want to sit around waiting.

So I forked the package, made the fix, pointed my composer.json at my fork, and moved on with the upgrade. Problem solved. Except now I'm maintaining a fork of a package I don't want to maintain. I need to remember to check back periodically, see if Bugsnag has released an update, and swap back to the official package when they do.

I'm not going to remember to do that. I'll forget within a week, and six months from now I'll be running a stale fork for no reason. I needed something that would tap me on the shoulder automatically.

The idea

Composer already knows which packages come from VCS repositories (forks) vs Packagist. GitHub's API can tell you if a repo is a fork and who the parent is. Packagist can tell you if the parent has released new versions. If you wire those three things together, you get a command that says "hey, the official package has been updated since you forked it, go check if you can drop your fork."

I wanted this to run automatically after every composer update so I'd never have to think about it. Here's the full command, broken down piece by piece.

The command skeleton

class CheckForkedPackagesCommand extends Command
{
    protected $signature = 'app:check-forked-packages';

    protected $description = 'Check if any forked Composer packages have been updated upstream on Packagist';

    /** @var array<string, array<string, mixed>> */
    private array $lockedPackages = [];

    public function handle(): void
    {
        $composerJson = json_decode(file_get_contents(base_path('composer.json')), true);
        $repositories = $composerJson['repositories'] ?? [];
        $ignored = $composerJson['extra']['check-forked-packages']['ignore'] ?? [];

        $this->loadLockedPackages();

        $githubForks = $this->getGitHubVcsRepositories($repositories, $ignored);

        if (empty($githubForks)) {
            $this->info('No GitHub VCS repositories found in composer.json.');

            return;
        }

        $this->info('Found '.count($githubForks).' GitHub VCS repositories. Checking for forks...');
        $this->newLine();

        $foundForks = false;

        foreach ($githubForks as $ownerRepo) {
            $this->checkRepository($ownerRepo, $foundForks);
        }

        if (! $foundForks) {
            $this->info('No forked packages found that exist on Packagist.');
        }
    }
}

The flow is simple. Read composer.json for VCS repositories, filter out anything in the ignore list, then check each one. The $lockedPackages array gets populated from composer.lock so we can figure out which branch is actually installed. More on that later.

The ignore list lives in composer.json under extra.check-forked-packages.ignore. This is for repos that are VCS repositories on purpose, not temporary forks you want to track. In my case, there are a couple of internal packages that will always point to private GitHub repos:

{
    "extra": {
        "check-forked-packages": {
            "ignore": [
                "myorg/internal-package",
                "myorg/shared-utilities"
            ]
        }
    }
}

Finding VCS repositories

The first real step is pulling VCS repositories out of composer.json and extracting the GitHub owner/repo from each URL.

private function getGitHubVcsRepositories(array $repositories, array $ignored): array
{
    $repos = [];

    foreach ($repositories as $repo) {
        if (($repo['type'] ?? '') !== 'vcs') {
            continue;
        }

        $url = $repo['url'] ?? '';

        if (! preg_match('#github\.com/([^/]+/[^/]+?)(?:\.git)?$#', $url, $matches)) {
            continue;
        }

        $ownerRepo = $matches[1];

        if (in_array($ownerRepo, $ignored)) {
            continue;
        }

        if (! in_array($ownerRepo, $repos)) {
            $repos[] = $ownerRepo;
        }
    }

    return $repos;
}

This only cares about VCS-type repositories hosted on GitHub. If you have a Composer-type repository (like a private Satis server or a paid package like Flux), it gets skipped. The regex pulls owner/repo from the URL, handling both https://github.com/owner/repo and https://github.com/owner/repo.git formats.

Loading the lock file

We need to know which branch of the fork is actually installed. That information lives in composer.lock, not composer.json. Each package entry in the lock file includes a source.url and a version, so we index the whole thing by source URL for quick lookups later.

private function loadLockedPackages(): void
{
    $lockFile = base_path('composer.lock');

    if (! file_exists($lockFile)) {
        return;
    }

    $lock = json_decode(file_get_contents($lockFile), true);
    $allPackages = array_merge($lock['packages'] ?? [], $lock['packages-dev'] ?? []);

    foreach ($allPackages as $package) {
        $sourceUrl = $package['source']['url'] ?? '';
        $this->lockedPackages[$sourceUrl] = $package;
    }
}

Why does the branch matter? Because if you forked off a feature branch (like feature/PLAT-15964), the relevant date isn't when the repo was forked. It's when that branch was created. A fork created six months ago might have a branch created yesterday. Checking against the fork date would give you false positives for every release in those six months.

Checking each repository

This is where the GitHub API comes in. For each VCS repository, we ask GitHub: is this a fork? If so, who's the parent?

private function checkRepository(string $ownerRepo, bool &$foundForks): void
{
    $this->comment("Checking {$ownerRepo}...");

    $response = Http::withHeaders(['Accept' => 'application/vnd.github.v3+json'])
        ->get("https://api.github.com/repos/{$ownerRepo}");

    if ($response->failed()) {
        $this->line('  Private or inaccessible repository. Skipping.');

        return;
    }

    $repoData = $response->json();

    if (! ($repoData['fork'] ?? false)) {
        $this->line('  Not a fork. Skipping.');

        return;
    }

    $parentFullName = $repoData['parent']['full_name'] ?? null;
    $defaultBranch = $repoData['default_branch'] ?? 'master';
    $forkedAt = $repoData['created_at'] ?? null;

    if (! $parentFullName || ! $forkedAt) {
        $this->warn('  Fork detected but could not determine parent or fork date.');

        return;
    }

    $branch = $this->getInstalledBranch($ownerRepo);
    $sinceDate = $forkedAt;
    $sinceLabel = "forked at {$forkedAt}";

    if ($branch && $branch !== $defaultBranch) {
        $branchCreatedAt = $this->getBranchCreatedAt($ownerRepo, $defaultBranch, $branch);

        if ($branchCreatedAt) {
            $sinceDate = $branchCreatedAt;
            $sinceLabel = "branch '{$branch}' created at {$branchCreatedAt}";
        }
    }

    $this->line("  Fork of {$parentFullName}, {$sinceLabel}");

    $this->checkPackagist($parentFullName, $sinceDate, $foundForks);
}

There's a cascade of early returns here. If the GitHub API fails (private repo, rate limited, whatever), skip it. If it's not actually a fork, skip it. If we can't determine the parent, skip it. Happy path last.

The interesting bit is the branch detection. getInstalledBranch checks the lock file to see if we're on a dev branch:

private function getInstalledBranch(string $ownerRepo): ?string
{
    $sourceUrl = "https://github.com/{$ownerRepo}.git";
    $package = $this->lockedPackages[$sourceUrl] ?? null;

    if (! $package) {
        return null;
    }

    $version = $package['version'] ?? '';

    if (str_starts_with($version, 'dev-')) {
        return substr($version, 4);
    }

    return null;
}

In my case, the Bugsnag fork is installed as dev-feature/PLAT-15964 as 2.29, so the version in the lock file is dev-feature/PLAT-15964 and the branch is feature/PLAT-15964. If you're on a tagged version instead of a dev branch, the method returns null and we fall back to the fork creation date.

Getting the branch creation date

Git doesn't actually store a "branch created at" date. Branches are just pointers to commits. But we can approximate it by comparing the branch to the the default branch and looking at the first divergent commit.

private function getBranchCreatedAt(
    string $ownerRepo,
    string $defaultBranch,
    string $branch
): ?string {
    $response = Http::withHeaders(['Accept' => 'application/vnd.github.v3+json'])
        ->get("https://api.github.com/repos/{$ownerRepo}/compare/{$defaultBranch}...{$branch}");

    if ($response->failed()) {
        return null;
    }

    $commits = $response->json('commits') ?? [];

    if (empty($commits)) {
        return null;
    }

    $firstCommit = $commits[0];

    return $firstCommit['commit']['committer']['date']
        ?? $firstCommit['commit']['author']['date']
        ?? null;
}

The GitHub compare API returns all commits that exist on the branch but not on the default branch. The first commit in that list is the earliest point where the branch diverged. Its date is close enough to "when work started on this branch" for our purposes.

Checking Packagist

Now we know the parent package and the date our fork diverged. The last step is asking Packagist if the parent has released anything since then.

But we have a problem. We know the parent's GitHub owner/repo, not its Composer package name. bugsnag/bugsnag-laravel on GitHub maps to bugsnag/bugsnag-laravel on Packagist, but that's not always the case. The package name comes from the name field in the parent repo's composer.json, so we fetch it directly from GitHub:

private function resolvePackagistPackage(string $githubFullName): ?string
{
    $response = Http::get("https://api.github.com/repos/{$githubFullName}/contents/composer.json");

    if ($response->failed()) {
        return null;
    }

    $content = base64_decode($response->json('content') ?? '');
    $composerData = json_decode($content, true);

    return $composerData['name'] ?? null;
}

With the package name in hand, we hit the Packagist API and compare version dates:

private function checkPackagist(string $parentFullName, string $sinceDate, bool &$foundForks): void
{
    $packagistPackage = $this->resolvePackagistPackage($parentFullName);

    if (! $packagistPackage) {
        $this->line('  Parent repo not found on Packagist. Skipping.');

        return;
    }

    $foundForks = true;
    $sinceTime = strtotime($sinceDate);

    $response = Http::get("https://repo.packagist.org/p2/{$packagistPackage}.json");

    if ($response->failed()) {
        $this->warn("  Could not fetch Packagist data for {$packagistPackage}.");

        return;
    }

    $versions = $response->json("packages.{$packagistPackage}") ?? [];
    $updatesAfterFork = [];

    foreach ($versions as $version) {
        $versionTime = $version['time'] ?? null;
        $versionName = $version['version'] ?? 'unknown';

        if ($versionTime && strtotime($versionTime) > $sinceTime) {
            $updatesAfterFork[] = [
                'version' => $versionName,
                'time' => $versionTime,
            ];
        }
    }

    if (empty($updatesAfterFork)) {
        $this->info("  ✓ {$packagistPackage} has no updates since your branch was created.");

        return;
    }

    $latest = $updatesAfterFork[0];

    $this->newLine();
    $this->warn("  ⚠ {$packagistPackage} has been updated since your branch was created!");
    $this->warn("    Latest: {$latest['version']} ({$latest['time']})");
    $this->warn('    Total updates since branch: '.count($updatesAfterFork));
    $this->warn('    Check if you can switch back to the official package.');
    $this->newLine();
}

The Packagist v2 API (repo.packagist.org/p2/) returns all versions of a package with their release timestamps. We loop through, collect everything released after our fork date, and report. If there are updates, you get a clear warning with the latest version and a count of total releases you've missed.

Hooking it into Composer

The command works great on its own, but I don't want to remember to run it. The whole point is that it runs itself. Composer has a post-update-cmd script hook that fires after every composer update:

{
    "scripts": {
        "post-update-cmd": [
            "@php artisan vendor:publish --tag=laravel-assets --ansi --force",
            "@php artisan app:check-forked-packages"
        ]
    }
}

Now every time I run composer update, the check runs automatically at the end. If Bugsnag (or any other forked package) has been updated upstream, I see the warning right there in my terminal. No calendar reminders, no mental overhead, no checking GitHub manually.

What the output looks like

When everything is quiet:

Found 1 GitHub VCS repositories. Checking for forks...

Checking TWithers/bugsnag-laravel...
  Fork of bugsnag/bugsnag-laravel, branch 'feature/PLAT-15964' created at 2026-03-18T14:22:31Z
  ✓ bugsnag/bugsnag-laravel has no updates since your branch was created.

When it's time to act:

Found 1 GitHub VCS repositories. Checking for forks...

Checking TWithers/bugsnag-laravel...
  Fork of bugsnag/bugsnag-laravel, branch 'feature/PLAT-15964' created at 2026-03-18T14:22:31Z

  ⚠ bugsnag/bugsnag-laravel has been updated since your branch was created!
    Latest: v2.30.0 (2026-04-01T09:15:00+00:00)
    Total updates since branch: 2
    Check if you can switch back to the official package.

That second output is what I'm waiting for. When it shows up, I check the changelog, confirm the fix I needed is included, remove the VCS repository from composer.json, update the version constraint, and run composer update. Fork gone, back on the official package. The whole thing is about a 30-second process, and the command told me exactly when to do it without me ever having to think about it

Forking a package shouldn't be a long-term commitment. With a few API calls and a Composer hook, it doesn't have to be.