Skip to content

[6.x] Reduce class autoloading during boot - #15381

Merged
jasonvarga merged 7 commits into
6.xfrom
boot-autoloading
Sep 7, 2026
Merged

jasonvarga merged 7 commits into
6.xfrom
boot-autoloading

Conversation

@duncanmcclean

@duncanmcclean duncanmcclean commented Sep 7, 2026

Copy link
Copy Markdown
Member

This pull request reduces the number of classes autoloaded while booting the app.

ExtensionServiceProvider was calling ::register() on every core action, dictionary, fieldtype, scope, tag, widget, JS driver and update script so each one could announce its handle. Reading the handle means autoloading the class, along with its parents and traits, so around 200 files were being loaded on every boot before anything had asked for a fieldtype or tag.

The same pattern was repeated on a smaller scale elsewhere: the API resources were validated with is_subclass_of, the Bard provider instantiated every tiptap extension up front, and the two Glide listeners injected PresetGenerator into their constructors, which Laravel resolves when it instantiates subscribers and pulls the whole Glide server chain in with it.

This PR fixes it by:

  • Keying the core extension arrays by handle (with tag aliases as extra keys) and writing them straight into statamic.extensions. ::register() is still the API for app and addon extensions, core just skips asking itself questions it already knows the answers to.
  • Registering update scripts and JS drivers the same way.
  • Binding the default API resources without validating them, since they're our own classes.
  • Wrapping Bard's tiptap extensions in closures, which Augmentor already supports.
  • Resolving PresetGenerator inside the Glide listeners' handlers instead of their constructors.

Measured on an M3 with opcache off, which is what tests and Artisan see:

before after
files autoloaded at boot 1293 980
cold boot ~135ms ~80ms

The saving is class loading, so it lands on cold boots: the first test in a PHPUnit process (per worker when running in parallel), Artisan commands, queue workers and any request without opcache. Warm boots with everything already in memory are unchanged. The reporter's numbers came from a slow filesystem, where the same file count costs proportionally more.

References #10753

duncanmcclean and others added 5 commits September 7, 2026 18:14
`ExtensionServiceProvider` was calling `::register()` on every core action, dictionary, fieldtype, scope, tag, widget, js driver and update script, which autoloads each class (and its parents and traits) just to read its handle. keying the arrays by handle lets the bindings be written straight into `statamic.extensions`, so around 200 fewer files are loaded on every boot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly4xenxkL1AcEdtf47qWjy
`Augmentor` already accepts closures, so wrapping the core extensions defers loading tiptap until something is actually augmented.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly4xenxkL1AcEdtf47qWjy
`is_subclass_of` autoloads every resource class on boot, and the defaults are our own classes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly4xenxkL1AcEdtf47qWjy
laravel instantiates event subscribers on boot, so injecting `PresetGenerator` into their constructors pulled the whole glide server chain in with them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly4xenxkL1AcEdtf47qWjy
# Conflicts:
#	src/Providers/ExtensionServiceProvider.php

@jasonvarga jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — the test file this PR edits is never executed by CI
tests/Assets/GeneratePresetImageManipulationsOnUpload.php

phpunit.dist.xml uses ./tests. This file has no Test suffix (class GeneratePresetImageManipulationsOnUpload), so it has never run in CI. That matters here because it is the only coverage for the GeneratePresetImageManipulations change, and the PR modifies it specifically to accommodate that change — so green CI gives no assurance the listener refactor works.

The naming is pre-existing, but this is the moment to fix it: I renamed the file and class in a scratch worktree at the PR's head and ran it — 7/7 pass. It is a free, verified fix.

Suggested fix: rename the file to GeneratePresetImageManipulationsOnUploadTest.php and the class to match.

(For reference, on 6.x the same file errors when invoked by path — Too few arguments to ...presets_are_generated_for_images() — because PHPUnit\Framework\Attributes\DataProvider was never imported. The PR adds that import, which is why it now passes.)

Warning — hardcoded handles duplicate handle() with nothing guarding the two in sync
src/Providers/ExtensionServiceProvider.php:29-260

The handle for every core extension now lives in two places: the array key here, and handle() / aliases() on the class. They agree today (verified exhaustively above), but nothing keeps them agreeing. There are no tests touching statamic.extensions or ExtensionServiceProvider at all.

Drift is silent and the failure modes are unpleasant:

Adding a new core fieldtype without a key (Fieldtypes\Foo::class on its own line) registers it under integer key 0 — and would clobber another 0 entry.
Renaming a class, or adding a protected static $handle, changes handle() while the registry key stays put. Lookups go through the registry so the fieldtype still resolves, but handle() is independently used at runtime for the Vue component name (Fieldtype::component()), the title translation key (statamic::fieldtypes..title), the docs URL, and FieldtypeRepository::makeSelectableInForms(self::handle()). The result is a fieldtype that half-works.
Suggested fix: add a test that walks each array and asserts the keys equal $class::handle() plus $class::aliases(). Cheap now, and it's the guard that makes the whole optimisation safe to maintain.

@duncanmcclean

Copy link
Copy Markdown
Member Author

Thanks for the review. Both addressed:

  • Renamed the listener test to GeneratePresetImageManipulationsOnUploadTest so PHPUnit actually picks it up.
  • Added ExtensionServiceProviderTest, which walks each registered extension type and asserts the registry keys match every class's handle() plus aliases(). Same check for the form JS drivers. It fails with an integer key in the diff if an entry is ever added without a handle.

@jasonvarga
jasonvarga merged commit 3641423 into 6.x Sep 7, 2026
65 checks passed
@jasonvarga
jasonvarga deleted the boot-autoloading branch September 7, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants