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
- Installation: requirements, a first script in plain PHP (run as written by the tests).
- Symfony and Laravel: no bridge yet; the registry as a service, by hand.
- Concepts: providers and their contracts, capabilities, factories, the registry, every model.
- Usage: profiles, sponsors, tiers, several platforms at once, caching.
- Configuration reference: the registry, every provider's options.
- Errors: the four exceptions and when each is thrown.
- Testing: the array provider, recorded answers.
- The Docker harness.
- Writing a provider.
- Verified, and not.
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/omnifundrequiressymfony/http-client-contracts(^3.0), interfaces only, and nothing else.- Each provider requires
omnisense/omnifund(1.x) andsymfony/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:
createConfig($options): the options in aConfig(anArrayObject), then the factory'spopulate()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);- the required options are checked: one missing,
''or[]is anInvalidConfigExceptionlisting every missing key (The "github-sponsors" provider needs: token.); 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 anUnreachableException, a status of 300 or more as aProviderException(GitHub answered 401 for the listing of x.), a body that is not a JSON object or list as aProviderException(... 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()(onlytrueis 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
BACKERrole, with since when, their tier and their total given; individuals areuser, organizationsorganization, other collectivesother. - Liberapay shows no patron by name:
$registry->sponsors('liberapay')throwsNotSupportedException.
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:
MONTHLYmonth,YEARLYyear,ONETIMEonce (anything else month); aFLEXIBLEamount 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 incapabilities(), 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 refusessponsors()with aNotSupportedException. - every call goes through
Http\Json::request(), which turns a network failure into anUnreachableExceptionand an error status into aProviderException; a GraphQLerrorsmember is aProviderExceptiontoo.Http\Json's typed readers walk an answer without trusting its shape. - amounts are
Model\Moneyin hundredths of the currency, with theModel\Periodthe 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.phplists the providers), on recorded answers (MockHttpClient), with aBareTestloading every class with the package's requirements alone, and aLiveTest(grouplive) 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
ProviderExceptionnaming 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.