Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Zend/zend_compile.c
Original file line number Diff line number Diff line change
Expand Up @@ -9686,6 +9686,20 @@ static void zend_compile_class_decl(znode *result, const zend_ast *ast, bool top
}
}

/* When compiling without execution (opcache_compile_file), link simple
* classes so opcache can early-bind them from cache. Skip preloading. */
if (!ce->num_interfaces && !ce->num_traits && !ce->num_hooked_prop_variance_checks
#ifdef ZEND_OPCACHE_SHM_REATTACHMENT
&& !ce->num_hooked_props
#endif
&& !extends_ast
&& (CG(compiler_options) & ZEND_COMPILE_WITHOUT_EXECUTION)
&& !(CG(compiler_options) & ZEND_COMPILE_PRELOAD)) {
zend_build_properties_info_table(ce);
zend_inheritance_check_override(ce);
ce->ce_flags |= ZEND_ACC_LINKED;
}

opline = get_next_op();

if (ce->parent_name) {
Expand Down
28 changes: 28 additions & 0 deletions ext/opcache/tests/gh18714.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
--TEST--
GH-18714 (opcache_compile_file() breaks class hoisting)
--EXTENSIONS--
opcache
--INI--
opcache.enable=1
opcache.enable_cli=1
--FILE--
<?php

// Class used before declaration relies on hoisting
file_put_contents(__DIR__ . '/gh18714_test.php', <<<'PHP'
<?php
$x = new HelloWorld();
echo get_class($x) . "\n";
class HelloWorld {}
PHP);

opcache_compile_file(__DIR__ . '/gh18714_test.php');
require_once __DIR__ . '/gh18714_test.php';

?>
--CLEAN--
<?php
@unlink(__DIR__ . '/gh18714_test.php');
?>
--EXPECT--
HelloWorld
13 changes: 13 additions & 0 deletions ext/opcache/zend_accelerator_module.c
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,11 @@ ZEND_FUNCTION(opcache_compile_file)
orig_compiler_options = CG(compiler_options);
CG(compiler_options) |= ZEND_COMPILE_WITHOUT_EXECUTION;

/* Save class/function table state so we can undo the side effects
* of zend_accel_load_script() called by persistent_compile_file(). */
uint32_t orig_class_count = EG(class_table)->nNumUsed;
uint32_t orig_function_count = EG(function_table)->nNumUsed;

if (CG(compiler_options) & ZEND_COMPILE_PRELOAD) {
/* During preloading, a failure in opcache_compile_file() should result in an overall
* preloading failure. Otherwise we may include partially compiled files in the preload
Expand All @@ -1001,6 +1006,14 @@ ZEND_FUNCTION(opcache_compile_file)
CG(compiler_options) = orig_compiler_options;

if(op_array != NULL) {
/* Undo classes/functions registered by zend_accel_load_script().
* opcache_compile_file() should only cache without side effects.
* Skip during preloading: preload needs the registrations to persist. */
if (!(orig_compiler_options & ZEND_COMPILE_PRELOAD)) {
zend_hash_discard(EG(class_table), orig_class_count);
zend_hash_discard(EG(function_table), orig_function_count);
}
Comment on lines +1009 to +1015
Copy link
Member

Choose a reason for hiding this comment

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

I think the old behavior may make sense not only for preloading.
Changing it may cause troubles for existing use cases.

I would propose to introduce the new behavior with an optional boolean argument, keeping the old behavior by default. What do you think?

Copy link
Contributor Author

@iliaal iliaal Mar 23, 2026

Choose a reason for hiding this comment

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

I think the registration is a side effect of the implementation path (persistent_compile_filezend_accel_load_script), not intentional behavior. The function compiles with ZEND_COMPILE_WITHOUT_EXECUTION, and registering classes and functions in the runtime tables is an execution side effect.

Users who call opcache_compile_file() then require_once the same file hit this bug because of that side effect: duplicate class/function registration. Making the fix opt-in means anyone who hits #18714 needs to discover a new parameter to get correct behavior.

If there are known use cases that depend on the registration happening, I'm open to adding the argument. I haven't found any in the docs or issue tracker though -- the documented purpose is cache warming, not class loading.


destroy_op_array(op_array);
efree(op_array);
RETVAL_TRUE;
Expand Down
Loading