Skip to content

omnisense/omnifund

Omnifund: who funds a project, read under any PHP host, wherever the funding is received - the profile (backers counted, income, goal), the sponsors and the tiers offered, from GitHub Sponsors, Open Collective or Liberapay. Providers are packages of their own (omnifund/github-sponsors, omnifund/opencollective, omnifund/liberapay); no framework in require.

Floor
Family
Version
1.x
Verified for real
Verified in part
Licence
MIT
composer require omnisense/omnifund:1.x-dev

Omnifund reads who funds a project, wherever the funding is received: the account's profile (its name and page, how many fund it, what it receives, its goal), its public sponsors and the tiers it offers - from GitHub Sponsors, Open Collective or Liberapay, through one set of models. A site shows a "Sponsors" band, a goal bar or a "Support us" page from any of them without knowing whose API answered.

$registry->profile('github')->profile()->goal;          // Model\Goal: "$500 a month", 12 %
$registry->sponsors('github')->sponsors();              // list<Model\Sponsor>, the public ones
$registry->tiers('oc')->tiers();                        // list<Model\Tier>
$registry->profile('liberapay')->profile()->income;     // Model\Income: 636.28 EUR a week

Each provider implements the contracts it can answer - ProfileInterface, SponsorsInterface, TiersInterface - and says so, with whether it needs a key and whose terms apply, in its Capabilities. The design is the Omnisense families' one: a provider's factory builds it from options, a registry holds the application's providers by name. The package is plain PHP: it requires symfony/http-client-contracts (interfaces only) and nothing else, and the HTTP client is the application's.

Published as omnisense/omnifund, formerly omniscient/omnifund: the package replaces the old name, so a composer.json that still requires it resolves to this package. The namespace is Omnifund\; providers keep the omnifund/ vendor and the Omnifund\<Provider>\ namespace.

When to use it

  • A site or a README badge that shows its sponsors, its backers or its funding goal, read from the platform that receives the money.
  • A project funded on more than one platform that wants one list and one total.
  • A "Support us" page that lists the tiers each platform offers.

It does not take payments, does not write anything to the platforms, and keeps nothing: no cache inside. Sponsors change slowly and every platform limits its calls: keep what is read in the application's cache.

Providers

Package Factory name Profile Sponsors Tiers Key Income counted by
omnifund/github-sponsors github-sponsors yes (no income) the public ones yes a GitHub token -
omnifund/opencollective opencollective yes (no goal) the backers yes none (a token raises the limit) the year (the yearly budget)
omnifund/liberapay liberapay yes - - none the week
Omnifund\Testing\ArrayFactory (in the core) array yes yes yes - as given

The contracts are what each provider class implements and its capabilities() says (probed in the harness on 2026-10-26). Liberapay shows no patron by name and has no tiers: the registry refuses sponsors('liberapay') and tiers('liberapay') with a NotSupportedException.

How this manual is organised

Propose a change docs/index.md

Installation

composer require omnisense/omnifund omnifund/liberapay symfony/http-client

Take the providers the project is funded on: omnifund/github-sponsors, omnifund/opencollective, omnifund/liberapay.

Requirements

  • PHP 8.2 or later.
  • omnisense/omnifund requires symfony/http-client-contracts (^3.0), interfaces only, and nothing else.
  • Each provider requires omnisense/omnifund (1.x) and symfony/http-client-contracts (^3.0): no HTTP client of its own. The application gives one - symfony/http-client, or any implementation of the contracts - to each factory's constructor; there is no default.
  • ext-json (always present since PHP 8.0).

Plain PHP

The block below runs as written in the tests (Tests/DocsTest.php, which checks the three printed lines against the comments), with a recorded answer in place of Liberapay's.

<?php

require 'vendor/autoload.php';

use Omnifund\Liberapay\LiberapayFactory;
use Omnifund\Registry;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

// In production: Symfony\Component\HttpClient\HttpClient::create(['timeout' => 10]).
$http = new MockHttpClient(new MockResponse('{"username":"Liberapay","display_name":null,"npatrons":1782,"receiving":{"amount":"636.28","currency":"EUR"},"goal":{"amount":"1000.00","currency":"EUR"},"summaries":[]}'));

$registry = new Registry([new LiberapayFactory($http)], [
    'liberapay' => ['factory' => 'liberapay', 'options' => ['account' => 'Liberapay']],
]);
$profile = $registry->profile('liberapay')->profile();

echo $profile->sponsors, " patrons\n";
// 1782 patrons
echo $profile->income?->monthly()?->decimal(), ' ', $profile->income?->amount->currency, " a month\n";
// 2757.21 EUR a month
echo $profile->goal?->percent, "% of the goal\n";
// 63% of the goal

Liberapay counts by the week: Income::monthly() turns 636.28 a week into a month of 52/12 weeks. Several providers sit in one registry, each under the name the application gives it:

use Omnifund\GitHubSponsors\GitHubSponsorsFactory;
use Omnifund\Liberapay\LiberapayFactory;
use Omnifund\OpenCollective\OpenCollectiveFactory;
use Omnifund\Registry;
use Symfony\Component\HttpClient\HttpClient;

$http = HttpClient::create(['timeout' => 10]);
$registry = new Registry([new GitHubSponsorsFactory($http), new OpenCollectiveFactory($http), new LiberapayFactory($http)], [
    'github' => ['factory' => 'github-sponsors', 'options' => ['account' => 'glitchr-studio', 'token' => $_ENV['GITHUB_TOKEN']]],
    'oc' => ['factory' => 'opencollective', 'options' => ['account' => 'webpack']],
    'liberapay' => ['factory' => 'liberapay', 'options' => ['account' => 'Liberapay']],
]);
foreach ($registry->sponsors('github')->sponsors() as $sponsor) { /* $sponsor->label(), ->avatarUrl, ->url */ }

Building a provider calls nothing: a provider is built the first time the registry is asked for it, its required options checked then (The "github-sponsors" provider needs: token.), and a platform is called only by profile(), sponsors() or tiers().

Cache what you read: sponsors change slowly, and each platform limits its calls (GitHub's GraphQL points, Open Collective's 10 anonymous calls a minute). omnifund keeps nothing itself (Usage).

No class of a framework is loaded on the way: Tests/BareTest.php runs the harness's bare script in a process of its own and checks that nothing but the package and the HTTP client contracts was loaded (harness).

In a framework

There is no Omnifund bundle and no provider in omnibridge/symfony or omnibridge/laravel yet: build the Registry once where the framework builds its services - Symfony, Laravel.

Propose a change docs/installation.md

Symfony

There is no Omnifund bundle, and omnibridge/symfony has no Omnifund wiring yet: a Symfony application declares the registry as a service of its own. The factories take the application's http_client by autowiring (Symfony\Contracts\HttpClient\HttpClientInterface).

# config/services.yaml
services:
    _defaults:
        autowire: true

    Omnifund\GitHubSponsors\GitHubSponsorsFactory: ~
    Omnifund\OpenCollective\OpenCollectiveFactory: ~
    Omnifund\Liberapay\LiberapayFactory: ~

    Omnifund\Registry:
        arguments:
            $factories:
                - '@Omnifund\GitHubSponsors\GitHubSponsorsFactory'
                - '@Omnifund\OpenCollective\OpenCollectiveFactory'
                - '@Omnifund\Liberapay\LiberapayFactory'
            $config:
                github: { factory: github-sponsors, options: { account: glitchr-studio, token: '%env(default::GITHUB_SPONSORS_TOKEN)%' } }
                oc: { factory: opencollective, options: { account: webpack } }
                liberapay: { factory: liberapay, options: { account: Liberapay } }
# .env.local, or bin/console secrets:set
GITHUB_SPONSORS_TOKEN=github_pat_...

Then inject the registry:

use Omnifund\Registry;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class SponsorsBand
{
    public function __construct(private readonly Registry $funding, private readonly CacheInterface $cache)
    {
    }

    /** @return list<array{name: string, picture: ?string, page: ?string}> */
    public function sponsors(): array
    {
        return $this->cache->get('sponsors.github', function (ItemInterface $item): array {
            $item->expiresAfter(3600);

            return array_map(static fn ($s) => ['name' => $s->label(), 'picture' => $s->avatarUrl, 'page' => $s->url],
                $this->funding->sponsors('github')->sponsors());
        });
    }
}

Nothing is built when the container compiles: a provider is built, and its required options checked, the first time the registry is asked for it. A token left empty (%env(default::...)% with the variable unset) shows then, as an InvalidConfigException.

To give each provider its own HTTP client options (a timeout, a scoped client), declare a scoped client in framework.http_client.scoped_clients and bind it to the factory's $http argument.

This page was written from the classes' constructors; it was not run in a Symfony kernel.

Propose a change docs/symfony.md

Laravel

omnibridge/laravel has no Omnifund provider: a service provider of the application builds the registry once, from a config file, with a symfony/http-client client (the providers take a Symfony\Contracts\HttpClient\HttpClientInterface; Laravel's own HTTP client is Guzzle's, which they do not take).

// config/omnifund.php
return [
    'providers' => [
        'github' => ['factory' => 'github-sponsors', 'options' => [
            'account' => 'glitchr-studio',
            'token' => env('GITHUB_SPONSORS_TOKEN'),
        ]],
        'liberapay' => ['factory' => 'liberapay', 'options' => ['account' => 'Liberapay']],
    ],
];
// app/Providers/OmnifundServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omnifund\GitHubSponsors\GitHubSponsorsFactory;
use Omnifund\Liberapay\LiberapayFactory;
use Omnifund\Registry;
use Symfony\Component\HttpClient\HttpClient;

final class OmnifundServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function (): Registry {
            $http = HttpClient::create(['timeout' => 10]);

            return new Registry([new GitHubSponsorsFactory($http), new LiberapayFactory($http)], config('omnifund.providers'));
        });
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then:

use Illuminate\Support\Facades\Cache;
use Omnifund\Registry;

$sponsors = Cache::remember('sponsors.github', 3600, fn () => array_map(
    static fn ($s) => $s->toArray(),
    app(Registry::class)->sponsors('github')->sponsors(),
));

Keep arrays in the cache (toArray()): the models are plain readonly objects, but arrays survive any cache store. Configuration caching (php artisan config:cache) keeps working: the options are plain arrays read through config(). This page was written from the registry's API; it was not run in a Laravel application.

Propose a change docs/laravel.md

Concepts

Provider and its contracts

Omnifund\ProviderInterface is one funding platform, configured for an account:

Method Returns
getName(): string the factory's name: github-sponsors, opencollective, liberapay, array
getTitle(): string the platform's display name: GitHub Sponsors, Open Collective, Liberapay, Fixed data
capabilities(): Capabilities what it can be asked, whether it needs a key, whose terms apply; calls nothing

A provider implements, beside it, the contracts it can answer. Each extends ProviderInterface, and each method reads the configured account, or another one given as $account:

Interface Method Returns
ProfileInterface profile(?string $account = null): Profile the account as it shows itself
SponsorsInterface sponsors(?string $account = null, ?int $limit = null): list<Sponsor> who funds it, as far as the platform shows them publicly; $limit null: every one, page after page
TiersInterface tiers(?string $account = null): list<Tier> the ways to give it offers

Capabilities

Omnifund\Capabilities (readonly):

Property Type Default
profile bool false implements ProfileInterface
sponsors bool false implements SponsorsInterface
tiers bool false implements TiersInterface
key bool false a key or token is needed to read it
terms list<string> [] the URLs of the terms whoever runs it must follow

contracts() lists the contracts it has (['profile', 'sponsors', 'tiers']), toArray() all of it. What each provider answers:

profile sponsors tiers key terms
GitHub Sponsors yes yes yes yes GitHub's Terms of Service, the GitHub Sponsors additional terms, the GraphQL rate limits
Open Collective yes yes yes no opencollective.com/tos, the API v2 documentation
Liberapay yes no no no liberapay.com/about/legal, liberapay.com/about/privacy
array yes yes yes no -

Factory and Config

Omnifund\ProviderFactoryInterface builds a provider from options: getName(): string and create(array $options = []): ProviderInterface. Every provider extends Omnifund\ProviderFactory, which does the work:

  1. createConfig($options): the options in a Config (an ArrayObject), then the factory's populate() sets its name (omnifund.factory_name), its title (omnifund.factory_title), its required options (omnifund.required_options) and the defaults of the others (Config::defaults() sets only keys not set yet);
  2. the required options are checked: one missing, '' or [] is an InvalidConfigException listing every missing key (The "github-sponsors" provider needs: token.);
  3. build($config) constructs the provider.

Nothing is called on the way. A factory whose provider is reached over HTTP takes the client in its own constructor - the three providers require one: there is no default client.

Config's readers, for a factory's build():

Method
get(string $key, mixed $default = null) the raw value
string(string $key): ?string a scalar as a string; null when it is empty
int(string $key, int $default = 0): int, float(string $key, float $default = 0.0): float a numeric value, else the default
bool(string $key): bool a bool, or "1", "true", "yes", "on" (any case) as an environment variable gives it
validateNotEmpty(list<string> $keys): void step 2

Registry

Omnifund\Registry holds the application's providers by name:

$registry = new Registry(
    [new GitHubSponsorsFactory($http), new LiberapayFactory($http)],     // iterable of factories, keyed by getName()
    [
        'github' => ['factory' => 'github-sponsors', 'options' => ['account' => 'glitchr-studio', 'token' => $token]],
        'liberapay' => ['factory' => 'liberapay', 'options' => ['account' => 'Liberapay']],
    ],
);
Method Returns
get(string $name): ProviderInterface the provider, built the first time and kept
profile(string $name): ProfileInterface the provider as a profile; NotSupportedException when it is not one
sponsors(string $name): SponsorsInterface likewise, as sponsors
tiers(string $name): TiersInterface likewise, as tiers
has(string $name): bool whether the name is configured
options(string $name): array its configured options
names(): list<string> the configured names
factories(): list<string> the factories installed: what factory may name

A name not configured is an InvalidConfigException (No "x" funding provider; configured: github, liberapay.), and so is a factory not given (No "missing" factory for the "nowhere" funding provider; installed: array.). There is no create() with overrides: a provider with other options is built with its factory, (new GitHubSponsorsFactory($http))->create([...]).

Money

Omnifund\Model\Money (readonly): int $cents, hundredths of the currency's unit, as the platforms count them (GitHub's monthlyPriceInCents, Open Collective's valueInCents, Liberapay's "636.28"), and string $currency, ISO 4217, upper-cased.

Method
__construct(int $cents, string $currency) InvalidConfigException when the currency is not three letters
fromDecimal(string|int|float $amount, string $currency): self from "636.28", 636.28, 5; InvalidConfigException when it is not numeric
decimal(): string "636.28", with a - for a negative amount
multiply(float $factor): self rounded to the cent
toArray() {cents, currency, decimal}

Every currency is counted in hundredths, whatever its minor unit: the providers' own amounts are.

Period

Omnifund\Model\Period (string enum): Once (once), Week (week), Month (month), Year (year). perMonth(): ?float is how many of this period make a month: 52/12 for a week (52 weeks a year), 1 for a month, 1/12 for a year, null for once.

Profile

Omnifund\Model\Profile (readonly), the account as it shows itself; each field null when the provider does not say:

Property Type
provider string the factory's name
handle string the account's login or slug, as the platform writes it
name ?string its display name
description ?string its short description, or summary
url ?string its funding page
avatarUrl ?string its picture
sponsors ?int how many fund it (private ones included where the platform counts them)
income ?Income what it receives, by period
goal ?Goal what it aims for

toArray() gives the same, nested models as arrays.

Income and Goal

Income (readonly): Money $amount and Period $period. monthly(): ?Money is the same income by the month (null for a one-time amount): 636.28 a week is 2757.21 a month.

Goal (readonly), what an account aims for - an amount, or a number of sponsors:

Property Type
title ?string GitHub's goal title
description ?string
amount ?Money an amount goal (Liberapay, GitHub's MONTHLY_SPONSORSHIP_AMOUNT)
period ?Period the amount's period
sponsors ?int a number of sponsors (GitHub's TOTAL_SPONSORS_COUNT)
percent ?int how far it has got, when the provider says (or computes)

Sponsor

Omnifund\Model\Sponsor (readonly), someone who funds the account, as the platform shows them publicly:

Property Type Default
handle string - their login or slug
name ?string null
avatarUrl ?string null
url ?string null their page
kind string Sponsor::USER user, organization or other (the constants USER, ORGANIZATION, OTHER)
since ?\DateTimeImmutable null since when (Open Collective)
tier ?string null their tier's name (Open Collective)
total ?Money null what they gave in all (Open Collective)

label() is the name, else the handle. Sponsors' names and pictures are personal data: show only what the platform shows publicly.

Tier

Omnifund\Model\Tier (readonly), one way to give:

Property Type Default
name string - "$5 a month", "Backer"
price ?Money null null for an amount left to the giver
period Period Month
id ?string null the platform's id
description ?string null
customAmount bool false the giver chooses the amount
minimum ?Money null the least accepted (Open Collective)

Http\Json

Omnifund\Http\Json is the providers' HTTP helper, public for an application's own provider:

  • request(HttpClientInterface $http, string $method, string $url, array $options, string $provider, string $what): array - the call, the HTTP client's failures as an UnreachableException, a status of 300 or more as a ProviderException (GitHub answered 401 for the listing of x.), a body that is not a JSON object or list as a ProviderException (... answered something that is not JSON for ...);
  • typed reads of an answer, each null (or [], false) when a step of the path is missing: get($data, ...$path), string(), int(), bool() (only true is true), list(), date().
$answer = json_decode('{"data":{"account":{"stats":{"contributorsCount":"42"},"since":"2016-10-15T15:47:40.951Z"}}}', true);
Json::int($answer, 'data', 'account', 'stats', 'contributorsCount');   // 42
Json::date($answer, 'data', 'account', 'since')?->format('Y-m-d');      // "2016-10-15"
Json::string($answer, 'data', 'nothing');                               // null

(the harness's bare script, run on 2026-10-26)

Propose a change docs/concepts.md

Usage

Profiles

$profile = $registry->profile('liberapay')->profile();          // the configured account
$other = $registry->profile('liberapay')->profile('another');   // another one, same provider

echo $profile->name ?? $profile->handle, ': ', $profile->sponsors, " patrons\n";
if ($monthly = $profile->income?->monthly()) {
    echo $monthly->decimal(), ' ', $monthly->currency, " a month\n";
}
if ($goal = $profile->goal) {
    echo $goal->title ?? $goal->amount?->decimal(), ', ', $goal->percent ?? '?', " %\n";
}

What each provider fills:

Profile GitHub Sponsors Open Collective Liberapay
handle the login the slug the username
name the user's or organization's name the collective's name the display name, else the username
description the listing's short description the description the summary in language, else the first one
url the listing's page, else the account's https://opencollective.com/<slug> <base_url>/<username>
avatarUrl the account's the logo (imageUrl) the avatar
sponsors sponsors.totalCount (private ones included) the backers (members(role: BACKER).totalCount) npatrons
income null: GitHub shows it to the account alone the yearly budget Open Collective estimates, by the year receiving, by the week; null when the account hides it
goal the active goal: an amount in dollars a month or a number of sponsors, its title, description and percentComplete null: the API gives none in a stable form goal, by the week, its percent computed from what it receives (rounded down); null when hidden

All three providers on recorded answers (run on 2026-10-26 in the harness, with the packages' own fixtures):

GitHub Sponsors (profile sponsors tiers): Octo Studio, 3 sponsors, goal $500 a month 12%
  sponsor Ada L. (user)
  sponsor ACME (organization)
  tier $5 a month: 5.00 month
  tier $25 one time: 25.00 once, any amount
Open Collective (profile sponsors tiers): Octo Collective, 3 sponsors, 179852.31 USD a year (14987.69 a month)
  sponsor Ada L. (user), Backer
  sponsor ACME (organization)
  tier Backer: 10.00 month
  tier Donation: - once, any amount
  tier Yearly sponsor: 1000.00 year
Liberapay (profile): Liberapay, 1782 sponsors, 636.28 EUR a week (2757.21 a month), goal 1000.00 63%

Sponsors

foreach ($registry->sponsors('github')->sponsors(limit: 24) as $sponsor) {
    printf('<a href="%s"><img src="%s" alt="%s"></a>', $sponsor->url, $sponsor->avatarUrl, $sponsor->label());
}

With no limit every sponsor is read, page after page (100 a page on both platforms); with a limit, no more pages than needed are asked for, each page no bigger than what is left.

  • GitHub Sponsors lists the public sponsors only (a private one is counted, never listed); users and organizations; no tier, since or total (GitHub shows them to the sponsor and the maintainer alone).
  • Open Collective lists the members with the BACKER role, with since when, their tier and their total given; individuals are user, organizations organization, other collectives other.
  • Liberapay shows no patron by name: $registry->sponsors('liberapay') throws NotSupportedException.

Tiers

foreach ($registry->tiers('oc')->tiers() as $tier) {
    echo $tier->name, ': ', $tier->price?->decimal() ?? 'any amount', ' ', $tier->period->value, "\n";
}
  • GitHub: monthly or one-time (isOneTime), in US cents (monthlyPriceInCents), custom amounts marked (isCustomAmount); the first 100 tiers.
  • Open Collective: MONTHLY month, YEARLY year, ONETIME once (anything else month); a FLEXIBLE amount type or frequency is a custom amount, with its minimum.
  • Liberapay has no tiers: NotSupportedException.

Several platforms

use Omnifund\SponsorsInterface;

$all = [];
foreach ($registry->names() as $name) {
    $provider = $registry->get($name);
    if ($provider instanceof SponsorsInterface) {
        array_push($all, ...$provider->sponsors());
    }
}

Check the contract (instanceof, or capabilities()->sponsors) before asking: the registry's typed getters throw for a provider that lacks it. Totals across platforms need one period: Income::monthly() brings a weekly or yearly income to a month.

Caching

omnifund keeps nothing: every call asks the platform, except that GitHub Sponsors and Open Collective keep the account's first answer (profile and tiers come from the same query) in the provider object for its lifetime. Keep what you show in the application's cache, as arrays (toArray()):

Platform Limit
GitHub 5,000 GraphQL points an hour per token; the profile and the tiers cost one query, each page of sponsors one more
Open Collective 10 calls a minute without a token; the same costs as GitHub
Liberapay none published; a non-profit run on donations: one call per profile, and its data changes weekly

Propose a change docs/usage.md

Configuration reference

The registry

new Registry(iterable $factories, array $config);
Key Type Required
<name> string - the name the application gives the provider: github, oc...
<name>.factory string yes a factory's name: github-sponsors, opencollective, liberapay, array
<name>.options array no ([]) the provider's options, below

Two accounts on the same platform are two entries with the same factory - or one entry, and the other account passed to each call (profile('another')).

Options are taken as given: unlike some Omnisense families, an empty string is not removed before the defaults apply. A required option given as null, '' or [] is reported missing; an optional one given as '' reads as not set (Config::string() gives null for it).

github-sponsors

Omnifund\GitHubSponsors\GitHubSponsorsFactory(HttpClientInterface $http)

Option Type Default Required
account string - yes the user or organization whose listing is read: glitchr-studio
token string - yes a GitHub token, sent as Authorization: Bearer. GitHub's GraphQL API answers no anonymous call; any token reads a public listing and its public sponsors - a fine-grained token with no permission is enough
endpoint string https://api.github.com/graphql no GitHub Enterprise: https://HOST/api/graphql
user_agent string omnifund (+https://github.com/glitchr-omnisense/omnifund) no sent with every call (GitHub refuses a request without one)

opencollective

Omnifund\OpenCollective\OpenCollectiveFactory(HttpClientInterface $http)

Option Type Default Required
account string - yes the collective's slug: webpack
token string '' (none) no a personal token, sent as Personal-Token when set: it raises the rate limit (10 calls a minute without one)
endpoint string https://api.opencollective.com/graphql/v2 no
user_agent string as above no

liberapay

Omnifund\Liberapay\LiberapayFactory(HttpClientInterface $http)

Option Type Default Required
account string - yes the username: Liberapay
language string en no the summary's language, when the account wrote several; else the first one
base_url string https://liberapay.com no the profile is <base_url>/<account>/public.json
user_agent string as above no Liberapay asks for one naming the application

array

Omnifund\Testing\ArrayFactory(), no HTTP client (Testing).

Option Type Default Required
profile Model\Profile - yes answered by profile(), whatever the account asked for
sponsors list<Model\Sponsor> [] no answered by sponsors(), cut to the limit; entries that are not a Sponsor are dropped
tiers list<Model\Tier> [] no answered by tiers()

A profile that is not a Profile, or sponsors / tiers that are not arrays, is an InvalidConfigException (The "array" provider needs a Profile, and lists of sponsors and tiers.).

The HTTP client

The client given to a factory is used as it is: its timeout, proxy and retries are the application's. HttpClient::create(['timeout' => 10]) is a reasonable start; Symfony's RetryableHttpClient may wrap it for Open Collective's 429s.

Propose a change docs/configuration.md

Errors

Every exception Omnifund throws implements Omnifund\Exception\OmnifundException (a \Throwable):

Exception Extends When
InvalidConfigException \InvalidArgumentException a provider not configured, a factory not installed, a required option missing; a Money with a currency that is not three letters, or an amount that is not numeric
NotSupportedException \LogicException the registry's profile(), sponsors() or tiers() asked of a provider without that contract: The "liberapay" funding provider (Omnifund\Liberapay\LiberapayProvider) does not list sponsors. Thrown before any call
ProviderException \RuntimeException the platform answered, and not with what was asked: a status of 300 or more, a body that is not JSON, a GraphQL errors member, no such account, no listing
UnreachableException \RuntimeException the platform could not be reached: a network error, a timeout; a copy of the HTTP client's exception is the previous one (no secret in it)

Messages, as the providers write them (run on recorded answers on 2026-10-26):

The "github-sponsors" provider needs: token.
GitHub answered 401 for the listing of x.
GitHub answered Could not resolve to a RepositoryOwner with the login of 'nobody-here-0'. for the listing of x.
GitHub answered no GitHub Sponsors listing for the listing of x.
GitHub answered something that is not JSON for the listing of x.
Liberapay could not be reached for the account x: Connection timed out

Open Collective writes Open Collective answered 429 for the backers of webpack., ... answered no such collective for the collective x.; Liberapay Liberapay answered 404 for the account x.. A status is not told apart further: a 401 (a bad GitHub token) and a 429 (a rate limit) are both a ProviderException, the status in its message only.

No secret in a failure

An UnreachableException keeps the request's secrets out. The HTTP client's message quotes the URL called: its key parameters, a password in its user part and every secret of the request's options (GitHub's and Open Collective's tokens) are masked (api_key=***, Http\Redaction::text()). The previous exception is a copy of the client's (Redaction::copy(): the same class and code, that message), and no trace of the chain keeps its arguments, where the query and headers stood: a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key. Nor does an error answered (a refused key, nothing there, a server error, a body that is not JSON): every exception Json::request() throws leaves its trace's arguments out.

What to catch where

Where Catch
a page that shows sponsors UnreachableException and ProviderException: show the last cached list, or nothing; log it
a scheduled refresh of the cache the same; on a ProviderException naming 401, check the token
building the registry, a deploy InvalidConfigException: a configuration error, to fix
a list over every provider check the contract first (instanceof); NotSupportedException is a programming error

Propose a change docs/errors.md

Testing

The array provider

Omnifund\Testing\ArrayProvider answers what it was given, with every contract: for tests, demos and an application's fixtures. Its factory, ArrayFactory (name array), takes the models as options:

use Omnifund\Model\Goal;
use Omnifund\Model\Income;
use Omnifund\Model\Money;
use Omnifund\Model\Period;
use Omnifund\Model\Profile;
use Omnifund\Model\Sponsor;
use Omnifund\Model\Tier;
use Omnifund\Registry;
use Omnifund\Testing\ArrayFactory;

$registry = new Registry([new ArrayFactory()], [
    'github' => ['factory' => 'array', 'options' => [
        'profile' => new Profile('array', 'glitchr-studio', 'Glitch Art', sponsors: 3,
            income: new Income(Money::fromDecimal('12.00', 'EUR'), Period::Week),
            goal: new Goal('A day a week', amount: Money::fromDecimal('100', 'EUR'), period: Period::Week)),
        'sponsors' => [new Sponsor('ada', 'Ada'), new Sponsor('acme', 'ACME', kind: Sponsor::ORGANIZATION, tier: 'Sponsor'), new Sponsor('bob')],
        'tiers' => [new Tier('Backer', Money::fromDecimal(5, 'EUR')), new Tier('Once', null, Period::Once, customAmount: true)],
    ]],
]);

$registry->profile('github')->profile()->income->monthly()->decimal();   // "52.00"
$registry->sponsors('github')->sponsors(limit: 2);                       // Ada, ACME

(the harness's bare script does the same, run on 2026-10-26.) The application keeps the name it uses (github); only the factory changes. getName() is array, getTitle() Fixed data; profile() answers the same profile whatever account is asked for.

A provider on recorded answers

To test the application's handling of a platform's answers and failures, give the factory a MockHttpClient:

use Omnifund\Exception\ProviderException;
use Omnifund\OpenCollective\OpenCollectiveFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$oc = (new OpenCollectiveFactory(new MockHttpClient(new MockResponse('{"message":"Rate limit exceeded"}', ['http_code' => 429]))))
    ->create(['account' => 'webpack']);

try {
    $oc->sponsors();
} catch (ProviderException $e) {
    // Open Collective answered 429 for the backers of webpack.
}

The providers' own tests keep the shapes of live answers (the people invented) in Tests/Fixtures/: listing.json, sponsors-1.json, sponsors-2.json, no-listing.json, not-found.json for GitHub; account.json, members-1.json, members-2.json, not-found.json for Open Collective; public.json, hidden.json for Liberapay. An application may start from them.

Both GraphQL providers send every query as a POST with {"query", "variables"}: a MockHttpClient callback can tell the listing (or account) query from the sponsors (or members) one by the query text.

Live checks

Each provider has a LiveTest (PHPUnit group live, left out of test) that reads a real account: docker compose run --rm omnifund live in the harness. Open Collective and Liberapay need no key; GitHub runs when OMNIFUND_GITHUB_TOKEN is set, and is skipped otherwise.

Propose a change docs/testing.md

The Docker harness

docker/ composes this core and every provider (docker/harness/plugins.php) into one installation, PHP 8.4, in the image omnisense/omnifund-harness. docker/harness/setup.php writes the harness's composer.json and phpunit.xml into a volume, and the entrypoint installs them when they change.

cd docker
docker compose run --rm omnifund test       # every package's tests, no network (the live group left out)
docker compose run --rm omnifund live       # the checks that reach Open Collective and Liberapay (no key), GitHub with OMNIFUND_GITHUB_TOKEN
docker compose run --rm omnifund bare       # the bare script: plain PHP, and what PHP loaded
docker compose run --rm omnifund phpstan    # PHPStan at the highest level, each package
docker compose run --rm -v "$PWD/scratch:/scratch" omnifund php /scratch/x.php   # a script of your own (autoloader: /harness/vendor/autoload.php)
docker compose down -v                      # the harness's volume gone

With no command, the entrypoint runs test; composer, sh and php run as they are; anything else exits 2 with the list. The service uses Docker's default bridge (network_mode: bridge): no network of its own is created.

Providers come from GitHub (branch 1.x) unless OMNIFUND_PLUGINS (in docker/.env) points at a directory holding their checkouts - ../.. when this core sits in the workspace beside them, as docker/.env sets it. OMNIFUND_GITHUB_TOKEN, from the environment or .env, is passed to the container for the GitHub live check.

Bare

docker/harness/bin/bare uses Omnifund in plain PHP - no bundle, no container, no framework: a registry with the array provider, its profile (a weekly income turned monthly, a goal), two of its sponsors, its tiers, a provider whose factory is not installed, an answer walked with the typed readers. It prints what happened and every class and file PHP loaded, as JSON; run on 2026-10-26, without the loaded part:

"monthly": "52.00",
"sponsors": ["Ada", "ACME"],
"tiers": [["Backer", "5.00", "month"], ["Once", null, "once"]],
"capabilities": ["profile", "sponsors", "tiers"],
"missing": "No \"missing\" factory for the \"nowhere\" funding provider; installed: array.",
"read": [42, "2016-10-15", null]

Tests/BareTest.php runs it in a process of its own and checks that nothing outside the package was loaded but Symfony's HTTP client contracts: no framework, no HTTP client, no application class. Each provider's BareTest loads every one of its classes with its required packages alone.

Propose a change docs/harness.md

Writing a provider

A provider is a package omnifund/<name> (namespace Omnifund\<Name>\) with a factory and a provider class, requiring omnisense/omnifund and symfony/http-client-contracts:

  • the factory extends Omnifund\ProviderFactory: populate() names it (omnifund.factory_name, omnifund.factory_title), lists its required options (omnifund.required_options) and the defaults of the others; build() returns the provider. The HTTP client comes through the factory's constructor (Symfony\Contracts\HttpClient\HttpClientInterface), never created inside.
  • the provider implements the contracts it can answer - ProfileInterface, SponsorsInterface, TiersInterface - and says so in capabilities(), with whether a key is needed and the terms that apply. A platform that keeps its sponsors private (Liberapay) implements the profile only; the registry then refuses sponsors() with a NotSupportedException.
  • every call goes through Http\Json::request(), which turns a network failure into an UnreachableException and an error status into a ProviderException; a GraphQL errors member is a ProviderException too. Http\Json's typed readers walk an answer without trusting its shape.
  • amounts are Model\Money in hundredths of the currency, with the Model\Period the platform counts in (Liberapay: the week; GitHub: the month; Open Collective's budget: the year).
  • tests run in the core's harness (docker/harness/plugins.php lists the providers), on recorded answers (MockHttpClient), with a BareTest loading every class with the package's requirements alone, and a LiveTest (group live) where the platform can be read without an account.

A skeleton

namespace Omnifund\Kofi;

use Omnifund\Capabilities;
use Omnifund\Config;
use Omnifund\Http\Json;
use Omnifund\Model\Profile;
use Omnifund\ProfileInterface;
use Omnifund\ProviderFactory;
use Omnifund\ProviderInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class KofiFactory extends ProviderFactory
{
    public function __construct(private readonly HttpClientInterface $http)
    {
    }

    protected function populate(Config $c): void
    {
        $c->defaults([
            'omnifund.factory_name' => 'kofi',
            'omnifund.factory_title' => 'Ko-fi',
            'omnifund.required_options' => ['account'],
            'user_agent' => 'my-site (+https://example.org)',
        ]);
    }

    protected function build(Config $c): ProviderInterface
    {
        return new KofiProvider($this->http, (string) $c->string('account'), (string) $c->string('user_agent'));
    }
}

final class KofiProvider implements ProfileInterface
{
    public function __construct(private readonly HttpClientInterface $http, private readonly string $account, private readonly string $userAgent)
    {
    }

    public function getName(): string { return 'kofi'; }

    public function getTitle(): string { return 'Ko-fi'; }

    public function capabilities(): Capabilities
    {
        return new Capabilities(profile: true, key: false, terms: ['https://ko-fi.com/terms']);
    }

    public function profile(?string $account = null): Profile
    {
        $account ??= $this->account;
        $data = Json::request($this->http, 'GET', 'https://example.org/'.rawurlencode($account).'.json',
            ['headers' => ['User-Agent' => $this->userAgent]], 'Ko-fi', "the account $account");

        return new Profile('kofi', Json::string($data, 'handle') ?? $account, Json::string($data, 'name'));
    }
}

The address and the fields are placeholders: a real provider reads the platform's documented answer. Ko-fi has no package; the skeleton shows the shape only.

What the three providers keep to

  • A call per question, and the account's first answer kept in the object when the profile and the tiers come from the same query (GitHub, Open Collective).
  • sponsors() pages until the end or the limit, never asking for more than the limit leaves.
  • A missing account is a ProviderException naming it (no such account, no such collective, no GitHub Sponsors listing), never an empty profile.
  • What the platform does not say stays null: no guessed income, no computed goal unless both amounts are given (Liberapay's percent).

Propose a change docs/providers.md

Verified, and not

What the tests prove

Run on 2026-10-26 in the harness, every package from its checkout, PHP 8.4, the live group left out, no network: 39 tests, 204 assertions, green.

  • The core: the models (amounts from decimals and back, a currency or an amount refused, an income by the month, a week as 52/12 of a month, a sponsor shown by name or handle, the typed readers); the registry (providers built once, a capability refused, a provider not configured, a required option missing); in plain PHP, a registry read loading nothing but omnifund and Symfony's HTTP client contracts, and every class loading with the required packages alone (BareTest); the installation page's script run as written (DocsTest).
  • Each provider on recorded answers (the shapes of live answers of 2026-10-24, the people invented): every request's endpoint, method, headers, query and variables; paging and limits; the profile, the goal (GitHub's amount and count goals), the tiers; an unknown account, an account without a listing, a bad token, a rate limit, an account hiding its income, the summary's language, the capability refused; the factories' required options.

The examples of this manual (the three providers on their fixtures, the error messages, the testing page) were run in the harness on 2026-10-26.

What was run live

The providers' READMEs state live runs on 2026-10-24 (docker compose run --rm omnifund live): Open Collective's GraphQL API without a key (webpack: profile, tiers, three backers), Liberapay's public.json (the Liberapay team), GitHub's GraphQL API with a token (glitchr-studio's listing, sindresorhus's tiers and two sponsors). They were not run again for this manual.

What is not verified

  • GitHub's goal as a live answer: no listing at hand had an active goal; the shape is GitHub's schema, accepted by the live query.
  • An Open Collective personal token.
  • Any site using omnifund: none yet.
  • The Symfony and Laravel pages were written from the classes' constructors, not run in an application.

Responsibility

omnisense/omnifund and its omnifund/* providers are unofficial clients, affiliated with none of the services they reach. The MIT licence covers the code only and grants no right to any service or its data; whoever runs them complies with each service's terms (named in each provider's README and in Capabilities::$terms) and obtains what they require. Sponsors' names and pictures are personal data: show only what the platform shows publicly, and follow its rules.

Propose a change docs/verified.md