php-stubs/acf-pro-stubs
PHP stubs for Advanced Custom Fields Pro, providing accurate function and class signatures so IDEs and static analysis tools like PHPStan and Psalm can offer autocompletion and type checking for ACF Pro APIs.
php-stubs/acf-pro-stubs is not runtime code — it is a single generated/curated PHP file that teaches static analysers and IDEs about the ACF PRO API (get_field, have_rows, acf_add_local_field_group, acf_register_block_type, the ACF class, etc.). Nothing is autoloaded, nothing ships to production, and it changes no behaviour.
Minimal setup (3 steps):
composer require --dev php-stubs/acf-pro-stubs php-stubs/wordpress-stubs
# phpstan.neon
includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon # optional but recommended
parameters:
level: 6
paths: [src, theme]
scanFiles:
- vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
- vendor/php-stubs/acf-pro-stubs/acf-pro-stubs.php
vendor/bin/phpstan analyse
Psalm users instead put the file under <stubs><file name="vendor/php-stubs/acf-pro-stubs/acf-pro-stubs.php"/></stubs>.
Where to look first:
vendor/php-stubs/acf-pro-stubs/acf-pro-stubs.php — the whole package. Grep it for the function you are unsure about; if it is not there, analysis will not know it.scanFiles snippet for your analyser.vendor/php-stubs/wordpress-stubs/wordpress-stubs.php alongside it — ACF stubs reference WP types (WP_Post, WP_Term) that come from that package.First use case: a theme or plugin that calls ACF functions, analysed in CI without WordPress installed. Before this package, PHPStan reports Function get_field not found on every template. After adding the two scanFiles entries, those disappear and real errors (wrong argument counts, typos like get_feild) surface.
Wrap get_field() — do not fight mixed every time. ACF's real return type is genuinely dynamic; the stubs correctly say mixed. The day-to-day pattern is a thin typed accessor per field type, written once:
/** @return array{url: string, alt: string, width: int, height: int}|null */
function theme_image(string $selector, int|string|null $post_id = null): ?array
{
$value = get_field($selector, $post_id);
return is_array($value) && isset($value['url']) ? $value : null;
}
/** @return list<array<string, mixed>> */
function theme_rows(string $selector, int|string|null $post_id = null): array
{
$value = get_field($selector, $post_id);
return is_array($value) ? $value : [];
}
Everything downstream (templates, view models, Blade/Twig data) consumes the wrappers, and PHPStan level 6+ stays clean without a single @phpstan-ignore.
Repeaters and flexible content. The stub signatures for have_rows() / the_row() / get_sub_field() / get_row_layout() / get_row_index() are accurate, so the canonical loop analyses fine:
if (have_rows('slides', $post_id)) {
while (have_rows('slides', $post_id)) {
the_row();
$heading = (string) get_sub_field('heading'); // still mixed, cast or wrap
}
}
while (have_rows('sections')) {
the_row();
match (get_row_layout()) {
'text_block' => get_template_part('blocks/text'),
'hero' => get_template_part('blocks/hero'),
default => null,
};
}
Options pages. get_field('logo', 'option') is typed mixed with $post_id accepting int|string|false|null. Wrap options reads the same way as post reads (theme_option('logo')) so the 'option' string is not scattered through templates.
Programmatic field groups. acf_add_local_field_group(array $field_group): void gives you existence checking but no shape checking. Pair it with an array-shape annotation so your IDE at least autocompletes the keys:
/** @var array{key: string, title: string, fields: list<array<string, mixed>>, location: list<list<array<string, mixed>>>} $group */
$group = [ /* ... */ ];
acf_add_local_field_group($group);
For anything larger than two groups, a small builder class returning that shape is worth more than the stubs alone.
ACF Blocks. acf_register_block_type() is covered, so registration arrays validate on argument count and on the render_template/render_callback keys you actually pass. Register inside acf/init:
add_action('acf/init', static function (): void {
acf_register_block_type([
'name' => 'hero',
'title' => __('Hero', 'theme'),
'render_template' => 'blocks/hero.php',
'category' => 'formatting',
'icon' => 'cover-image',
'mode' => 'preview',
'supports' => ['align' => true, 'jsx' => true],
]);
});
CI wiring. Run the stubs against the whole theme, not just src/. Generate a baseline once (--generate-baseline) on a legacy codebase, then shrink it; new code gets zero tolerance. Cache .phpstan keyed on composer.lock so a stub bump invalidates it.
paths (common when WordPress lives in the repo), PHPStan sees two definitions of get_field and errors. Exclude wp-content/plugins/advanced-custom-fields-pro from paths/excludePaths — the stubs replace it.scanFiles, not stubFiles. scanFiles registers the symbols; stubFiles overrides signatures and is the escape hatch if a stub is wrong. Confusing the two produces "function not found" or silent no-ops. The package ships no extension.neon, so phpstan/extension-installer will not pick it up automatically — the manual scanFiles entry is mandatory.scanFiles rather than sprinkling ignores.mixed is correct, not a bug. Every get_field() return trips PHPStan level 6+. Fix it with wrapper functions (see above) or assert(is_string($x)), not with a global mixed tolerance — the whole point of the stubs is that the other 90% is checked.'hero_titel' is a typo; field keys, names and types are runtime data. Consider a constants class or enum for field names if string-typo bugs are recurring.--dev and deploy with composer install --no-dev. Committing the stubs into the repo is a smell; committing a phpstan.neon that references vendor/... is correct.stubs/acf-helpers.stub.php (generic helpers, project-specific acf_* wrappers, missing functions) in scanFiles next to the vendor file, so upgrading the package never overwrites your additions.acf/init, acf/save_post, acf/validate_value are WP actions; the stubs describe ACF's functions but not hook signatures, so callback argument types are still on you. Annotate the callback with @param and PHPStan will check the body.How can I help you explore Laravel packages today?