melentev-av / opmin
Minimizes PHP opcodes without changing behavior: counts opcodes per function, applies Rector and LLM rewrites, verifies behavior on every step.
Requires
- php: >=8.3
- internal/container: ^1.1
- internal/path: ^1.4
- nikic/php-parser: ^5.4
- phpstan/phpdoc-parser: ^2.3
- psr/container: 1 - 2
- rasuvaeff/property-testing-core: 1.2.0
- rector/rector: ~2.7.0
- symfony/console: ^7.3 || ^8
- symfony/process: ^7.3 || ^8
- symfony/yaml: ^7.3 || ^8
Requires (Dev)
- infection/infection: ^0.35
- rasuvaeff/property-testing-testo: 1.1.0
- spiral/code-style: ^2.3.1
- testo/bridge-infection: 0.1.9
- testo/bridge-rector: 0.3.3
- testo/testo: 0.10.55
- vimeo/psalm: ^6.10 || ^7.0
Suggests
- ext-pdo_sqlite: Keeps the cache in one SQLite database (cache.driver: auto) instead of a file per entry
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-11 18:15:23 UTC
README
Minimizes PHP opcodes without changing behavior.
opmin counts opcodes of every function, method and closure (after the OPcache optimizer), rewrites code with Rector rules and an LLM stage, and keeps a change only when the opcode count went down and the behavior is proven unchanged: syntax and PHPStan, the project's own tests, and differential testing of the old and the new version of each function on generated inputs (return values, exceptions, output, warnings, by-ref arguments, object state, calls to collaborators).
As a CI guard, opmin check fails a pull request when functions grow in opcodes compared to a committed baseline.
Status: 0.x. Counting, verification, both optimization stages, the report, the CI guard and delivery (static binary, PHAR, Docker image,
doctor,self-update, git packages) work. Formats and options may still change before 1.0.
Installation
opmin needs two PHPs, and the binary brings one of them:
- the PHP that runs opmin — embedded in the static binary (or your PHP 8.3+ for the PHAR);
php.binary— the PHP of your production (8.1–8.5, with OPcache). It compiles the code for counting, runs the differential tests and your tests, so its version must be the one you deploy.opmin doctorchecks it first.
1. Static binary (recommended) — Linux x86_64/aarch64 (static, any distribution), macOS x86_64/arm64:
curl -fsSL https://raw.githubusercontent.com/melentev-av/opmin/master/install.sh | sh curl -fsSL https://raw.githubusercontent.com/melentev-av/opmin/master/install.sh | sh -s -- --dir=/usr/local/bin --version=<version> opmin self-update # the latest release; --check only tells; --to=<version> installs that one
install.sh puts opmin into ~/.local/bin (or --dir), checks the SHA-256 against sha256sum.txt of the
release and, with openssl, the signature of sha256sum.txt. self-update always checks both and starts the new
binary once before replacing the old one. Archives are also on
GitHub Releases — see «Verifying a download» below.
2. PHAR — the same scoped build for your own PHP 8.3+: opmin.phar on the release page.
Composer — composer require --dev melentev-av/opmin installs the sources with their dependencies (Rector
2.7, nikic/php-parser 5, Symfony Console/Process/Yaml 7.3+ or 8, PHPStan phpdoc-parser) into your vendor/. If
they conflict with the project (e.g. it is on Rector 1 or php-parser 4), use the PHAR or the static binary.
3. Docker — ghcr.io/melentev-av/opmin:<version>-php<8.1…8.5>: the binary plus the PHP of production with
OPcache (JIT off), pcov, git and composer. Pick the tag of your production PHP; the images are meant for CI and
for git packages. The floating php<8.1…8.5> tag follows the latest release.
docker run --rm -v "$PWD:/app" ghcr.io/melentev-av/opmin:<version>-php8.3 opmin doctor
Pinning the version per project. Commit a .opmin-version (an exact version x.y.z, or a constraint like
^x.y; for 0.x ^0.4 means >=0.4.0 <0.5.0) or set requires: the same way in opmin.yaml: another opmin
stops with exit code 2 and the opmin self-update --to= command to run. A global opmin started inside a
project that has its own — vendor/bin/opmin (installed by composer) or an opmin binary in the project root
(e.g. downloaded by dload) — runs that one instead
(OPMIN_NO_DELEGATE=1 keeps the global one).
From sources (development): git clone git@github.com:melentev-av/opmin.git && cd opmin && composer install && bin/opmin list.
Verifying a download
Every release has sha256sum.txt, its ECDSA P-256 signature sha256sum.txt.sig and GitHub build provenance
attestations. The public keys are resources/release-keys (a primary one and a reserve
one kept offline):
sha256sum --check --ignore-missing sha256sum.txt openssl dgst -sha256 -verify primary.pub.pem -signature sha256sum.txt.sig sha256sum.txt gh attestation verify opmin-<version>-linux-x86_64.tar.gz --repo melentev-av/opmin
Quick start
opmin init # opmin.yaml: every key with a comment; php.target from php.binary, paths, test runner, formatter, PHPStan; # ends with the next commands; on a terminal offers to run doctor (--doctor / --no-doctor) opmin doctor # php.binary first, then OPcache, the harness, syntax, tests, PHPStan, coverage, git — with fixes opmin count # opcodes per function opmin optimize # Rector stage, every accepted step is a separate commit # then, in Claude Code: "minimize opcodes with opmin" — the skill opcode-minimize (LLM stage) opmin baseline # commit opmin.baseline.json, then `opmin check` in CI keeps opcodes from growing
A run ends with a summary like this, and .opmin/report_<ts>.md with every function:
------ ------------------------------------ ------ ------------- --------- ----------------
Pass Rule Kept Rolled back Opcodes Commit / error
------ ------------------------------------ ------ ------------- --------- ----------------
1 FullyQualifyGlobalCallsRector 14 2 -85 039d2f6a
1 ExtractRepeatedPropertyFetchRector 2 1 -6 bc951c85
1 ExtractRepeatedArrayDimFetchRector 1 1 -2 4d3f2f4b
------ ------------------------------------ ------ ------------- --------- ----------------
The full run of the project's tests passes on the result.
[OK] 442 → 349 opcodes (-93). Report: .opmin/report_20261007-192240.md, patch: .opmin/runs/20261007-192240/opmin.patch
opmin doctor exits with 1 when something blocks count/optimize (warnings — e.g. no pcov, so every step
runs the whole test suite — do not); --with-tests also runs your test suite once.
Counting opcodes
opmin count src lib/Legacy.php # table: function, file:line, ops_raw, ops_opt, vars, tmps opmin count --filter='App\Service\*' --exclude=tests opmin count --top=20 # the 20 largest functions by ops_opt opmin count --min=50 --sort=ops_raw # functions of 50+ ops_opt, largest ops_raw first opmin count --format=json > before.json # deterministic report, keys sorted opmin diff before.json after.json # per function: fewer / more opcodes, new, removed
- Code is compiled by
php.binary(your production PHP, 8.1–8.5) with OPcache, never executed and never by the PHP running opmin.ops_opt— opcodes after the optimizer — is the main metric;ops_rawis before it. - Every function, method, property hook (8.4+), closure and the code outside functions (
src/x.php::<main>, counted but not optimized) gets a stable key:App\Foo::bar,App\Foo::bar::{closure:2},App\Foo::bar::{class:1}::bazfor anonymous classes — ordinals, not line numbers. Trait methods are counted once, in the trait; abstract and interface methods are not reported. --sort=file|ops_opt|ops_raworders the table: by file and line (default), or by opcodes, most first.--top=Nkeeps the first N rows and sorts byops_optunless--sortis given;--min=Nkeeps functions with at least Nops_opt. Both leave out main code (it is not optimized) and the per-file table; the totals stay those of the whole report. The three shape the table only:--format=jsonis the full reportdiffreads, so with them it exits with code 2 — cut the JSON withjqinstead.- Counts depend on the PHP version: reports carry
phpandoptimizer_hash, anddiffrefuses to compare reports taken with different ones. - Counts are cached in
.opmin/cache/by file content, PHP version, optimizer settings and opmin version: re-counting an unchanged project does not start PHP for compilation at all. A relativecache.diris next to the config in use, so in a monorepo the packages share the root's cache.cache.driverpicks the storage:auto(default) is one SQLite database.opmin/cache/cache.sqlitewhen the PHP running opmin haspdo_sqlite(the static binary and most PHP builds do) and a JSON file per entry otherwise;sqliteinsists on the database (a config error, exit code 2, withoutpdo_sqlite);filesis always a file per entry;memorykeeps counts and references for one run only, nothing on disk. Both disk stores are shared by parallel runs; a corrupt or locked database only costs the cache, never the run (opmin doctornames it — delete the file and the next run creates it anew, or setcache.recreate_corrupt: trueto let runs do it).
Verifying behavior
opmin verify src/Cart.php /tmp/Cart.php # differential tests of every changed function opmin verify src/Cart.php /tmp/Cart.php --with-tests # + php -l, PHPStan "no new errors", the project's tests
- Every changed function is called in two harness workers under
php.binary— one with the original file, one with the candidate — on the same inputs: boundary values of its parameter types, values a non-strict caller may pass, literals of both versions, random inputs and coverage-guided mutations. Return values, exceptions, output, warnings (also thrown asErrorException), by-ref arguments,$this, mock calls, globals and statics are compared. Time and randomness in the function's namespace are faked; a function that disagrees with itself is nondeterministic and stays unverified. - Each call is limited by
verification.call_timeout_ms(1000 ms); an input the original hangs on is dropped, and after 5 such inputs in a row the function stays unverified ("the original times out on generated inputs"). An input on which the original runs out ofverification.memory_limit(and the candidate does the same) is dropped and counted in that row too ("the original runs out of memory on generated inputs"). The whole test of one function is limited byverification.time_budget_ms(15 s; a typical function takes 0.1–3.5 s): when it runs out, the inputs checked so far decide through the coverage threshold below, as with the search limitverification.fuzz_time_ms. The shrinking of a difference is never cut short. - A difference is shrunk to a minimal input and saved to
.opmin/runs/<timestamp>/counterexamples/as JSON and as a test for the project's runner (PHPUnit, Pest, Testo; a plain script otherwise). - Accepted: proven by differential tests with
verification.min_branch_coverageof the original's branches, or unverified but executed by passing project tests, or withverification.allow_unverified. Branches the project's PHPStan proves dead (--with-tests: always-true or impossible conditions, unreachable code) are not counted. Functions with I/O are never called with generated inputs: only the project's tests prove them. A call through a variable or a callback counts as I/O when the function names an I/O function in a string ($f = 'shell_exec'; $f(...)), and the harness itself cannot start processes or send mail (exec,shell_exec,system,passthru,proc_open,popen,pcntl_exec,mailare disabled): a function that still reaches one stays unverified. - The differential tests of one step run
verification.jobsat a time, each in a process of its own (--jobs=Nfor one run).0(auto, the default) is half of the CPU cores — a test keeps two harness workers busy — at most 4, within the free memory (2 ×verification.memory_limitper test);1runs them one after another in the opmin process. The inputs do not depend on it (verification.seed), but the time limits (call_timeout_ms,fuzz_time_ms,time_budget_ms) do: for verdicts reproducible across machines, e.g. in CI, use--jobs=1.--guard-perftimes the kept changes afterwards, one by one. - Flags of
opmin count(compact,static_var,eval,io…) say which constructs restrict a function.
Optimizing (Stage A: Rector)
opmin optimize # the config `paths` (src/ or app/) opmin optimize src/Cart.php 'src/**/*Service.php' --dry-run opmin optimize --with-standard-rector # also the standard Rector rules (off by default) opmin optimize --review # confirm every change: y / n / a (all of this rule) / q opmin optimize --resume # continue the latest interrupted run (or --resume=.opmin/runs/<ts>) opmin optimize --guard-perf # also roll back changes that make a function slower opmin optimize --rector-rule=fqn --rector-rule=count # only these rules: alias, short or full class name opmin optimize --plan # say what the run would use and exit: nothing is changed or tested
-
--rector-ruletakes the alias of a rule (see Excluding code), the short class name of a rule of the run (HoistLoopInvariantCountRector) or the full class name of any installed rule; an unknown name is an error with the closest one (Unknown Rector rule `fqm` in --rector-rule. Did you mean `fqn`? …). -
Every run starts with a header on stderr: the files and how many functions they have (the counts come from the cache), the rules, the test runner, whether
php.binaryhas a coverage driver, PHPStan, the formatter and the differential tests at a time (verification.jobsresolved). When the runner is a plaintests.commandor there is neither pcov nor Xdebug, a warning says at once that every step runs the whole test suite.--planprints the header and exits with 0: no tests run, no files change, no run directory is written.Optimizing 12 file(s) of /app with 4 rule(s), PHP 8.4.1 (target 8.1), git: a commit per accepted step. Functions: 230, 214 of them optimizable Rules: FullyQualifyGlobalCallsRector, ExtractRepeatedPropertyFetchRector, ExtractRepeatedArrayDimFetchRector, HoistLoopInvariantCountRector Tests: phpunit, the tests that cover a changed function Coverage driver: yes (pcov or Xdebug in php.binary) PHPStan: vendor/bin/phpstan Formatter: vendor/bin/php-cs-fixer fix --quiet --path-mode=intersection {files} (detected) Differential tests: 4 at a time (verification.jobs: 0, auto) -
Rules are applied one by one, in passes until a pass changes nothing (
rector.max_passes). After each rule: the project's formatter on the changed files (Pint, PHP-CS-Fixer, ECS, PHPCBF — detected, orcommands.format,--format=none),php -l, a count. A changed function is kept only when it saves opcodes, the gain is worth the changed lines (readability.*), complexity and nesting do not grow and its signature stays assignatures.*allow; then it is verified on all three levels. Everything else is taken back. -
In a git working tree every accepted step is a commit of its files; outside git the originals are copied to
.opmin/runs/<ts>/original/;--dry-runrestores everything. Each run writes the report (below), the counts before and after andopmin.patch, and ends with a full run of the project's tests. -
Under git opmin does not check the tree by default: its commits and rollbacks name their paths, so other dirty files stay as they are, but your own uncommitted edits of a file the run changes go into opmin's commit.
--with-git(orgit.require_clean: targets) requires the files the run may change to be committed, andgit.require_clean: allasks for a clean tree. -
--reviewshows each change that passed every check — the diff of the function,−N opcodes, rule X, checks: diff-tested 96% (210 inputs) ✓— and asksy(apply),n(roll back and never propose again),a(apply this and the rest of the rule),q(roll back and end the run). Declined changes go toopmin.baseline.yaml(rejected: [{function, rule}], committed at the end of the run; remove an entry to allow it again) and are skipped by both stages. Without an interactive session (-n, CI, an agent) every change that passes is applied. -
--guard-perf(orguard_perf.enabled) times every diff-tested change: both versions on up to 20 of the inputs of its differential test, compiled by OPcache as in production, in alternating order over 7 rounds; the median change of the time goes to the report, and a change slower by more thanguard_perf.max_regression_percent(5) is rolled back even with fewer opcodes. Changes proven only by the project's tests are not timed. -
Ctrl+C (SIGINT, SIGTERM) stops a run: the step under way is dropped — its tools got the signal too — and the report says the run was interrupted. Every step is saved to
.opmin/runs/<ts>/state.json;--resumeputs the files back to the last accepted step (also after a crash orkill -9, dropping an opmin commit the state missed) and continues with the same files, rules and options, ending as an uninterrupted run would. -
The progress goes to stderr, the summary to stdout. In a terminal it is one line redrawn in place — pass, rule, phase,
verifying 12/47(functions of the round whose differential test is done),round 2after a take-back, the current function, the time since the start — with the result of every step and the warnings above it. Without a terminal (CI, a pipe,--no-ansi) it is a log: every line with the time and the seconds since the start ([14:02:11 +83s] Pass 1: FullyQualifyGlobalCallsRector), a result line per step (kept 4, rolled back 1, -12 opcodes (51s)), the tests on the original code and the coverage map.-vadds the phases inside a verification: PHPStan, the project's tests, each differential test. A missing coverage map is a warning at the first verification: every step then runs the whole test suite. -
A change rolled back for a reason of its own (no gain, a difference, not proven…) is not tried again in later passes while the function stays the same.
-
opmin's own rules, on by default, prove their safety conditions themselves (native types through PHPStan):
FullyQualifyGlobalCallsRector(strlen()→\strlen(), only when no namespaced function or test mock — php-mock, ClockMock — can shadow it),ExtractRepeatedPropertyFetchRector(readonly properties across calls, mutable ones while no user code runs, no__get/hooks),ExtractRepeatedArrayDimFetchRector(only keys proven to exist),HoistLoopInvariantCountRector(\count()of an unchanged local array out of aforcondition). For the two extraction rules, a call of a known pure built-in function (\explode(),\strlen(),\abs()…) with arguments of scalar native types runs no user code (Opmin\Rector\Support\PureFunctionCall). -
Code can be excluded from every rule or from some: see Excluding code.
-
Which standard Rector rules save opcodes: docs/standard-rules.md.
Optimizing (Stage B: LLM, a Claude Code skill)
What rules cannot do, a model proposes and opmin judges — one function at a time, through the same checks as a
Rector step. opmin init puts the skill opcode-minimize into .claude/skills/ (--no-skill to skip);
opmin skill:install --global puts it into ~/.claude/skills/, opmin skill:update brings it to the version of
the installed opmin. The skill drives these commands:
opmin llm:targets src --format=json # starts a session in .opmin/runs/<ts>/: the llm.top_n functions with the most opcodes opmin llm:context 'App\Cart::total' # source, opcode listing, histogram, dynamic constructs with their rules, # readability and signature rules, rejected attempts with their reasons opmin apply-candidate 'App\Cart::total' .opmin/runs/<ts>/candidate.php --next --format=json opmin llm:finish # the full test run (taking back attempts while it fails), report, patch
-
apply-candidate --nextaddsnextto the result, so a target costs onellm:contextcall at most:retrythe same function (a rejection with attempts left; the reason is inattempt),rewritethe next unfinished target in the session order with itscontext(asllm:context --format=jsonprints it), orfinish. The candidate can come on stdin (-) instead of a file. -
A candidate is the new source of only that function; anything else changed (another function, an import) is refused. It is kept only with strictly fewer opcodes, within
readability.*andsignatures.*, and proven by PHPStan, the project's tests and differential tests; then it is a commit. A rejection names the reason — with the shrunk counterexample input when behavior differs — and goes into the context of the next attempt. -
At most
llm.attempts_per_functionattempts per function.#[\Opmin\Ignore(rules: ['llm'])]/@opmin-ignore llmkeeps a function away from this stage only. -
The patterns the skill knows are measured on PHP 8.1–8.5 (
bin/bench --only=patterns), including the ones that usually give nothing: resources/skills/opcode-minimize/PATTERNS.md.
The report
Every run (optimize, llm:finish) writes .opmin/runs/<ts>/report.md for people — and the same file as
.opmin/report_<ts>.md, next to the reports of the earlier runs — and .opmin/runs/<ts>/report.json for tools:
- the total: opcodes before → after, %; whether the full run of the project's tests passes;
- what to do next (
adviceinreport.json, at the top ofreport.md, and at the end of the console output next to the functions that saved most): keyed on the cause of the rolled-back changes and on what the run had to prove them with — settests.runner; enable pcov or Xdebug inphp.binarywhen the runner gives no coverage map; fix the autoload (composer install, thenopmin doctor) when the harness could not load the code; add tests for functions with side effects; the counterexamples of a difference; thesignatures.*settings. Expected rollbacks (no gain, readability, excluded, declined in the review) are only counted.--allow-unverifiedis never the advice: it comes last, with a warning; - every changed function: before → after, what saved the opcodes (rule or LLM), how it is proven (
diff-tested,tests,unverified), the branch coverage of the differential tests, the project's tests that run it, the flags of dynamic constructs; changes that save executed opcodes rather than static ones are listed apart; - every rolled-back change grouped by reason (a difference with its counterexample input, failing tests, PHPStan,
not proven, signature, dynamic constructs, readability, no gain, excluded); in
report.jsoneach one has akind(the group) and a machine-readablecause— what to fix first so that it could be kept, e.g.not_proven.no_runner,not_proven.no_coverage_map,not_proven.harness_load,not_proven.side_effects,not_proven.no_inputs,diff_test,signature.public_api; - counterexamples as tests of the project's runner in
.opmin/runs/<ts>/counterexamples/; - the environment: PHP runtime,
php.target, the optimizer settings, opmin, Rector, the project's PHPStan. A run warns when one of them changed since the previous run,opmin diffwhen the reports come from other opmin versions or targets; - where the time went (
report.json,timings): seconds and calls per level — Rector, the formatter,php -l, counting, PHPStan before and after, the project's tests, the differential tests (worker start, planned inputs, fuzzing, guard-perf), commits — with the once-per-run work (once.*: the tests on the original code, the coverage map, the final test run) apart, and counters of steps, verification rounds, differential tests cut byverification.time_budget_ms(time_budget_spent) and stopped by timeouts of the original (original_timeouts_stop) or by running out of memory (original_out_of_memory_stop);-vprints the same at the end of the run. Every check of a function also has itsseconds.
report.json has "schema": 1; its fields change only in a major release (new fields may be added).
Runs: runs, runs:show, runs:clean
Every optimize, LLM session (llm:targets … llm:finish) and verify with a counterexample leaves a
directory in .opmin/runs/<ts>/ (-2, -3… for runs of the same second and for the runs of a git package
copied from its clone). They are kept until you delete them:
opmin runs # id, kind, state, opcodes before → after, size; newest first opmin runs --format=json # {"runs": [{id, kind, state, ops_before, ops_after, size, report, backup, path}]} opmin runs:show # report.md of the newest run that has a report, to stdout opmin runs:show 20261011-062031 --format=json # report.json as it is; --format=path prints where report.md is opmin runs:clean --keep=5 --dry-run # what would be deleted opmin runs:clean --keep=5 # delete it, with the copies .opmin/report_<ts>.md
- kinds:
optimize(state.json),llm(llm.json),verify(onlycounterexamples/),package(copied from the clone ofoptimize <git-url>),broken(no state and no report: a run that failed before it saved anything); states:finished,interrupted(an optimize run--resume=<dir>continues),open(an LLM session not finished yet),broken; runs:shownever opens a viewer; exit code 2 when there is no such run or it has no report (an interrupted, a verify or a broken run);runs:cleankeeps the--keepnewest finished runs, every interrupted optimize run and every open LLM session (any of them can still be continued), and a broken run written in the last hour (it may be one that is still starting). Outside git theoriginal/of a run that changed the code (it has a patch, or it did not finish) and was not a dry run is the only copy of the code before the run: such runs are listed and kept unless--include-backups. The command asks nothing and prints what it deletes; exit code 2 for a missing or negative--keep;runs.keep: Ninopmin.yaml(defaultnull, off) prunes with the same rules (never the backups) every timeoptimizeorllm:targetscreates a run; a failure to delete is a warning and never fails the run.
CI guard: baseline and check
opmin baseline # writes opmin.baseline.json — commit it opmin check # functions of files changed since check.base_ref (origin/main) opmin check --base=origin/develop --format=github opmin check --all # the whole project opmin check --update-baseline # write decreased, new and removed functions to the baseline opmin check --suggest # for grown functions: which Rector rule would take the opcodes back
opmin.baseline.jsonholdsops_optper function key (App\Foo::bar,App\Foo::bar::{closure:2}— ordinals, not lines) with its file, plusphpandoptimizer_hash. Keys are sorted and there are no lines, so its diff in a PR shows only functions whose counts changed. Code outside functions (<main>) is not guarded. Not to be confused withopmin.baseline.yaml— the changes you declined inoptimize --review.- The changed files are
git diff --name-only <base>(committed and uncommitted) plus untracked files; a rename is a removal and an addition, so a function moved to another file keeps its key and a renamed one is reported as removed + new. - A function that grew by more than
check.tolerancefails the check; a decrease, a new function and a removed one ask you to update the baseline (--update-baselinedoes it, keeping grown functions at their old counts — accepting growth is an explicitopmin baseline); a new function abovecheck.max_ops_new_functionis a warning.--suggestapplies every Stage A rule alone to temporary copies (the project is not touched) and names the one that saves the most — unverified:opmin optimizeproves the change. - Exit codes:
0— nothing grew;1— opcodes grew (or a checked file cannot be compiled);2— no baseline, a baseline taken with another PHP minor version or optimizer settings (nothing is compared: rebuild it withopmin baselineon the PHP of the check), an unknown base ref, or invalid config. - Formats:
table(default),json(every finding, totals),github(workflow annotations on the PR lines),gitlab(Code Quality report),checkstyle. The report goes to stdout, messages to stderr.
check: tolerance: 0 # by how many opcodes a function may grow max_ops_new_function: null # limit for new functions, null — no limit base_ref: origin/main # base of the changed-files mode (--base=)
Which image. The check runs in ghcr.io/melentev-av/opmin:<version>-php<X.Y>, <X.Y> being the PHP minor
of your production — ask production itself, not composer.json ("php": "^8.1" says nothing about the PHP
that runs the code):
php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' # on production: e.g. 8.3
- Pin the opmin version:
:0.4.0-php8.3, not the floating:php8.3. The floating tag follows the latest release, and a new opmin may count some functions differently (a counting fix) — the check of an unchanged PR would then fail or ask to update the baseline. Bump the version on purpose, together with the baseline. - The baseline must be taken with the same image as the check: the same PHP minor and the same optimizer
settings (
optimizer_hash: the OPcache INI of opmin and the loaded Zend extensions); another one is rejected with exit code 2. Create it with that image and commitopmin.baseline.json:docker run --rm -v "$PWD:/app" ghcr.io/melentev-av/opmin:<version>-php8.3 opmin baseline. opmin init --ci=githubwrites.github/workflows/opcodes.yml(the action below),--ci=gitlabwrites.gitlab/opmin.ymland prints theinclude:line for.gitlab-ci.yml(which it never edits). The PHP is the minor ofphp.binary(notphp.target: that may be the lowestrequire.phpof composer.json) and the image is the running opmin's; it prints both and thedocker run … opmin baselinecommand. An existing file is never overwritten (exit code 2).
GitHub Actions (.github/workflows/opcodes.yml):
name: Opcodes on: pull_request jobs: opmin: runs-on: ubuntu-latest container: ghcr.io/melentev-av/opmin:<version>-php8.3 # the PHP of your production steps: - uses: actions/checkout@v5 with: fetch-depth: 0 # the base branch is needed for the changed-files mode - run: opmin check --base="origin/${{ github.base_ref }}" --format=github
Or the action of this repository (action.yml, composite): it runs opmin check in the same image with
docker run, so it needs a Linux runner. The version of the image is the tag of the action — Dependabot
(package-ecosystem: github-actions) bumps it like any other uses:; bump the baseline with it.
name: Opcodes on: pull_request jobs: opmin: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: fetch-depth: 0 # the base branch is needed for the changed-files mode - uses: melentev-av/opmin@v<version> with: php: '8.3' # the PHP minor of your production
Inputs: php (required), version (the opmin release of the image; default — the tag of the action, required
when it is used at a branch or a commit), base (default origin/<base branch> of the pull request,
check.base_ref outside one), format (default github), args (more arguments of opmin check, e.g.
--suggest).
GitLab CI (.gitlab-ci.yml):
opcodes: stage: test image: ghcr.io/melentev-av/opmin:<version>-php8.3 # the PHP of your production rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" variables: GIT_DEPTH: 0 script: - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" - opmin check --base="origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --format=gitlab > gl-code-quality.json artifacts: when: always reports: codequality: gl-code-quality.json
Excluding code
#[\Opmin\Ignore] // the function, method or class: every rule, both stages #[\Opmin\Ignore(rules: ['fqn', 'llm'])] // only these rules // @opmin-ignore // the same without the dependency, right before the declaration /** @opmin-ignore fqn,count */ // or in its docblock
ignore: paths: [src/Legacy, database/migrations] functions: ['App\Foo\Bar::hotPath', 'App\Utils\*'] # function keys, * matches anything
-
The attribute is recognized by its name in the source: the class
Opmin\Ignoreneed not be installed (PHP checks attributes only innewInstance()); with opmin inrequire-devthe IDE knows it. -
A mark on a class covers all its methods and closures.
vendor/and files marked@generatedare never touched. -
Rule names in
rules:and@opmin-ignore— an alias, the short or the full class name of any rule, case-insensitive (\Opmin\Rector\Rule\FullyQualifyGlobalCallsRectorcounts as its short name). A name that is none of the names below nor a rule of the run excludes nothing — a typo would let opmin change the code — sooptimizewarns about it, with the closest name, in the console and in the report:[WARNING] src/Cart.php:12: `@opmin-ignore` lists `fqm`, which names no known rule, so it excludes nothing. Did you mean `fqn`? Known names: fqn, property_fetch, array_dim_fetch, count, llm, or the short class name of a rule.It is a warning, not an error: a mark may name a standard Rector rule that this run does not enable.
Alias Rule fqnFullyQualifyGlobalCallsRectorproperty_fetchExtractRepeatedPropertyFetchRectorarray_dim_fetchExtractRepeatedArrayDimFetchRectorcountHoistLoopInvariantCountRectorllmStage B: rewrites proposed by the model simplifyifreturnboolrector, …a standard Rector rule by its short class name
Optimizing a git package
opmin optimize https://github.com/vendor/package.git --ref=v2.1.0 opmin optimize git@github.com:vendor/package.git src/Parser --ref=main --dry-run
composer install and the tests of a package are foreign code, so by default:
- the package is cloned on your machine (your git credentials) into a temporary workspace — with Docker under
~/.cache/opmin/packages(the directory Docker Desktop, colima and OrbStack share),package.workspacechanges it — on a branchopmin/<timestamp>, and removed afterwards (links inside are never followed); composer install --no-scripts --no-plugins;--allow-scriptsruns them when the package needs them;- opmin then optimizes the clone inside Docker — the image of the newest PHP the package's
require.phpallows (orphp.target): no network, a read-only root file system except the workspace, the memory limitpackage.memory(4g) andpackage.cpus, your user.--no-dockerruns on this machine after a confirmation (--yesin scripts), and only ifphp.binarysatisfiesrequire.phpand has theext-*the package needs; - the public API is never changed (
signatures.public_apiis forced off): a package is called from code nobody can see.--force-public-apilifts it; .opmin/runs/<ts>/with the report andopmin-<package>-<ref>.patchland in the current directory: apply the patch in your fork withgit apply.
package.docker_image (ghcr.io/melentev-av/opmin:{version}-php{php}) selects another image.
The package's tests must be green on the original code without network, or optimize stops before changing anything. Exclude the tests that need network, or verify with the differential tests only:
opmin optimize https://github.com/thephpleague/csv.git --ref=9.28.0 \ '--set=tests.command=vendor/bin/phpunit --exclude-group=network' opmin optimize https://github.com/symfony/string.git --ref=v6.4.46 --set=tests.runner=none # no PHPUnit of its own
On macOS the workspace lives on the case-insensitive file system Docker shares with its VM: a dependency whose archive has names that differ only in case (phpstan/phpstan) cannot be installed there. Run opmin inside the image instead, with the workspace on the file system of the container:
docker run --rm -v "$PWD:/out" ghcr.io/melentev-av/opmin:php8.4 sh -c \ 'cd /tmp && opmin optimize https://github.com/briannesbitt/Carbon.git --ref=3.14.2 --no-docker --yes -n; cp -r .opmin opmin-*.patch /out/'
Results on real packages
An example: real packages optimized in git package mode with Stage A (the four own Rector rules), then their own
tests run on the patched code and stay green. Measured in CI; the run can be repeated by hand
(smoke-real-packages.yml, or tests/Smoke/run.sh <package> <php>
locally).
| Package | PHP | Opcodes | Saved | Functions changed | Package's tests after the patch | Time |
|---|---|---|---|---|---|---|
| symfony/string 6.4.46 | 8.1 | 5 940 → 5 863 | −77 (−1.3%) | 50 | green | 2 min |
| 8.5 | 6 358 → 6 083 | −275 (−4.3%) | 45 | green | 2 min | |
| league/csv 9.28.0 | 8.1 | 16 679 → 16 605 | −74 (−0.4%) | 24 | green (without network) |
1.5 min |
| 8.5 | 16 512 → 16 404 | −108 (−0.7%) | 26 | green (without network) |
1.5 min | |
| nesbot/carbon 3.14.2 | 8.1 | 26 823 → 26 629 | −194 (−0.7%) | 53 | green (without localization) |
5.5 min |
| 8.5 | 27 413 → 26 160 | −1 253 (−4.6%) | 106 | green (without localization) |
8 min |
php.binary and php.target are the PHP of the row; CI runners, the static binary. Most of the gain is
FullyQualifyGlobalCallsRector, and the gap between 8.1 and 8.5 is its: since PHP 8.4 an unqualified call of a
frameless function (trim, str_replace, implode…) in a namespace compiles into both the frameless call and the
fallback, and \trim() removes the fallback.
Troubleshooting
Start with opmin doctor (--with-tests also runs your test suite once): it checks php.binary, OPcache, the
harness, the tests, PHPStan, coverage and the cache, and names a fix for each problem.
- Every step runs the whole test suite, slowly.
php.binaryhas neither pcov nor Xdebug, so there is no coverage map to pick the tests that cover a changed function (the run header andopmin doctorwarn about it). Install pcov intophp.binary(pecl install pcov), or use the Docker image, which has it. - «The project's tests fail on the original code».
optimizestops before it changes anything: a red suite proves nothing. Fix the tests, exclude the failing ones (tests.commandwith PHPUnit's--exclude-group, as in Optimizing a git package), or settests.runner: noneto verify with the differential tests only. - A git package does not install on macOS. The workspace is on the case-insensitive file system Docker shares with its VM; run opmin inside the image, as in Optimizing a git package.
- «Cannot list the internal functions of …».
php.binaryprints warnings to stdout at startup — usually its ini is loaded twice (e.g. Laravel Herd, orPHP_INI_SCAN_DIRpointing at a directory that loads the same extension again:PHP Warning: Module "…" is already loaded). Run that PHP with-v: it must print no warnings. Remove the duplicateextension=line (php --inilists the files), or setdisplay_errors=stderrin that ini. opmin 0.3.1 and later name the exit code and the output. opmin checkexits with 2: «The baseline was taken with PHP X, php.binary is PHP Y». Counts differ between PHP minor versions and optimizer settings, so nothing is compared. Take the baseline with the same image as the check (docker run … opmin baseline, see CI guard) and commit it.- The cache is corrupt or locked. It only costs the cache, never the run;
opmin doctornames the file. Delete it (the next run creates it anew), or setcache.recreate_corrupt: trueto let runs do it. - A change was rolled back as «not proven». Nothing proved the function behaves as before: the generated
inputs reached too little of its code, it has side effects, or no project test runs it. «What to do next» at
the top of the report (
opmin runs:show) says what to fix — settests.runner, enable coverage, add tests. [WARNING] … `@opmin-ignore` lists `…`, which names no known rule. A typo in a mark excludes nothing, so opmin may change that code: fix the name (the warning suggests the closest one).- A run takes long.
timingsin.opmin/runs/<ts>/report.json(-vprints them at the end) shows where the time went: the project's tests, PHPStan or the differential tests. Without a coverage map every step runs all tests (see above);verification.jobs(or--jobs=N) runs more differential tests at a time;--planshows the files, rules and settings a run would use without running it.
Configuration
opmin.yaml (or opmin.yaml.dist) in the project root; opmin init generates it with all keys. The commands that
analyze a project (count, verify, optimize, check, baseline, llm:*, apply-candidate) refuse to run
without it (exit code 2): they read their settings from it and keep the cache next to it. Everything opmin
writes goes to .opmin/ next to the config — .opmin/runs/<ts>/ (reports, patches, counterexamples) and
.opmin/cache/ — and init or the first such command adds .opmin/ to .gitignore of a git project. A git package
(opmin optimize <git-url>) without its own config gets an empty one beside the clone. Unknown keys and
wrong types are errors naming the key (exit code 2), with the closest key when there is one — a typo or a key in
the wrong section:
Unknown config key `seed` (opmin.yaml). Did you mean `verification.seed`?
Unknown config key `verification.sed` (--set). Did you mean `verification.seed`?
Any key can be overridden for one run:
OPMIN_VERIFICATION_SEED=7 opmin optimize . opmin optimize . --set=verification.seed=7 --set=rector.standard.enabled=true
IDE completion comes from the JSON Schema referenced in the first line of the generated file (resources/opmin.schema.json).
What opmin is not
- Not a profiler and not a guarantee of speed: fewer static opcodes is not always faster code
(
--guard-perfmeasures time where benchmarks exist). - Not a code style tool: changes that do not reduce opcodes or cost too much readability are rejected.
Development
composer test # Testo: unit, integration, rector suites composer psalm # static analysis composer cs:diff # code style make phar # scoped PHAR in .build/phar/opmin.phar bin/playground init # manual end-to-end scenarios in playground/ (git-ignored)
Branches: development happens in develop; master only receives release merges. Commits follow
Conventional Commits.
License
MIT, see LICENSE.md. The CLI skeleton is derived from DLoad (BSD-3-Clause), see NOTICE.md.