diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs
index 10ab17389..6dd6cc679 100644
--- a/crates/macros/src/lib.rs
+++ b/crates/macros/src/lib.rs
@@ -12,6 +12,7 @@ mod impl_interface;
mod interface;
mod module;
mod parsing;
+mod php_clone;
mod syn_ext;
mod zval;
@@ -398,6 +399,122 @@ extern crate proc_macro;
/// echo Counter::getCount(); // 2
/// ```
///
+/// ## Using Classes as Properties
+///
+/// By default, `#[php_class]` types cannot be used directly as properties of
+/// other `#[php_class]` types because they don't implement `FromZval`. To
+/// enable this, derive `PhpClone` on any class that needs to be used as a
+/// property.
+///
+/// The class must implement `Clone`, and deriving `PhpClone` will implement
+/// `FromZval` and `FromZendObject` for the type, allowing PHP objects to be
+/// cloned into Rust values.
+///
+/// ```rust,ignore
+/// use ext_php_rs::prelude::*;
+///
+/// // Inner class that will be used as a property
+/// #[php_class]
+/// #[derive(Clone, PhpClone)] // PhpClone enables use as a property
+/// pub struct Address {
+/// #[php(prop)]
+/// pub street: String,
+/// #[php(prop)]
+/// pub city: String,
+/// }
+///
+/// #[php_impl]
+/// impl Address {
+/// pub fn __construct(street: String, city: String) -> Self {
+/// Self { street, city }
+/// }
+/// }
+///
+/// // Outer class containing the inner class as a property
+/// #[php_class]
+/// pub struct Person {
+/// #[php(prop)]
+/// pub name: String,
+/// #[php(prop)]
+/// pub address: Address, // Works because Address derives PhpClone
+/// }
+///
+/// #[php_impl]
+/// impl Person {
+/// pub fn __construct(name: String, address: Address) -> Self {
+/// Self { name, address }
+/// }
+///
+/// pub fn get_city(&self) -> String {
+/// self.address.city.clone()
+/// }
+/// }
+///
+/// #[php_module]
+/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
+/// module
+/// .class::
()
+/// .class::()
+/// }
+/// ```
+///
+/// From PHP:
+///
+/// ```php
+/// name; // "John Doe"
+/// echo $person->address->city; // "Springfield"
+/// echo $person->getCity(); // "Springfield"
+///
+/// // You can also set the nested property
+/// $newAddress = new Address("456 Oak Ave", "Shelbyville");
+/// $person->address = $newAddress;
+/// echo $person->address->city; // "Shelbyville"
+/// ```
+///
+/// ### Clone Semantics
+///
+/// When reading a property that uses `PhpClone`, PHP receives a **clone** of
+/// the Rust value. This has important implications:
+///
+/// ```php
+/// $address = new Address("123 Main St", "Springfield");
+/// $person = new Person("John Doe", $address);
+///
+/// // Reading $person->address returns a CLONE
+/// $addressCopy = $person->address;
+/// $addressCopy->city = "Modified City";
+///
+/// // The original is unchanged because $addressCopy is a clone
+/// echo $person->address->city; // Still "Springfield"
+///
+/// // To modify the original, you must reassign the property
+/// $person->address = $addressCopy;
+/// echo $person->address->city; // Now "Modified City"
+/// ```
+///
+/// ### Rc/Arc Considerations
+///
+/// If your type contains `Rc`, `Arc`, or other reference-counted smart
+/// pointers, cloning will create a new handle that **shares** the underlying
+/// data with the original. This means mutations through the shared reference
+/// will affect both the original and the clone.
+///
+/// **Important notes:**
+///
+/// - The inner class must derive both `Clone` and `PhpClone`
+/// - When accessed from PHP, the property returns a clone of the Rust value
+/// - Modifications to the returned object don't affect the original unless
+/// reassigned
+/// - Types with `Rc`/`Arc` will share interior data after cloning
+///
+/// See [GitHub issue #182](https://github.com/extphprs/ext-php-rs/issues/182)
+/// for more context.
+///
/// ## Abstract Classes
///
/// Abstract classes cannot be instantiated directly and may contain abstract
@@ -2316,6 +2433,88 @@ fn zval_convert_derive_internal(input: TokenStream2) -> TokenStream2 {
zval::parser(input).unwrap_or_else(|e| e.to_compile_error())
}
+/// # `PhpClone` Derive Macro
+///
+/// Derives [`FromZendObject`] and [`FromZval`] for **owned** (non-reference)
+/// types that implement [`Clone`] and [`RegisteredClass`]. This enables using
+/// `#[php_class]` structs as properties of other `#[php_class]` structs.
+///
+/// ## Important: Clone Semantics
+///
+/// This macro creates a **clone** of the PHP object's underlying Rust data when
+/// reading the property. This has important implications:
+///
+/// - **Reading** the property returns a cloned copy of the data
+/// - **Writing** to the cloned object will NOT modify the original PHP object
+/// - Each read creates a new independent clone
+///
+/// If you need to modify the original object, you should use methods on the
+/// parent class that directly access the inner object, rather than reading
+/// the property and modifying the clone.
+///
+/// ## Rc/Arc Considerations
+///
+/// If your type contains [`Rc`], [`Arc`], or other reference-counted smart
+/// pointers, be aware that cloning will create a new handle that shares the
+/// underlying data with the original. This means:
+///
+/// - Mutations through the shared reference WILL affect both the original and
+/// clone
+/// - The reference count will be incremented
+/// - This may lead to unexpected shared state between PHP objects
+///
+/// Consider using deep cloning strategies if you need complete isolation.
+///
+/// [`Rc`]: std::rc::Rc
+/// [`Arc`]: std::sync::Arc
+/// [`FromZendObject`]: ext_php_rs::convert::FromZendObject
+/// [`FromZval`]: ext_php_rs::convert::FromZval
+/// [`RegisteredClass`]: ext_php_rs::class::RegisteredClass
+///
+/// ## Example
+///
+/// ```rust,ignore
+/// use ext_php_rs::prelude::*;
+///
+/// #[php_class]
+/// #[derive(Clone, PhpClone)]
+/// struct Bar {
+/// #[php(prop)]
+/// value: String,
+/// }
+///
+/// #[php_class]
+/// struct Foo {
+/// #[php(prop)]
+/// bar: Bar, // Now works because Bar implements FromZval via PhpClone
+/// }
+/// ```
+///
+/// PHP usage demonstrating clone semantics:
+/// ```php
+/// $bar = new Bar("original");
+/// $foo = new Foo($bar);
+///
+/// // Reading $foo->bar returns a clone
+/// $barCopy = $foo->bar;
+/// $barCopy->value = "modified";
+///
+/// // Original is unchanged because $barCopy is a clone
+/// echo $foo->bar->value; // Outputs: "original"
+/// ```
+///
+/// See:
+#[proc_macro_derive(PhpClone)]
+pub fn php_clone_derive(input: TokenStream) -> TokenStream {
+ php_clone_derive_internal(input.into()).into()
+}
+
+fn php_clone_derive_internal(input: TokenStream2) -> TokenStream2 {
+ let input = parse_macro_input2!(input as DeriveInput);
+
+ php_clone::parser(input)
+}
+
/// Defines an `extern` function with the Zend fastcall convention based on
/// operating system.
///
@@ -2500,10 +2699,14 @@ mod tests {
}
fn runtime_expand_derive(path: &PathBuf) {
+ type DeriveFn = fn(TokenStream2) -> TokenStream2;
let file = std::fs::File::open(path).expect("Failed to open expand test file");
runtime_macros::emulate_derive_macro_expansion(
file,
- &[("ZvalConvert", zval_convert_derive_internal)],
+ &[
+ ("ZvalConvert", zval_convert_derive_internal as DeriveFn),
+ ("PhpClone", php_clone_derive_internal as DeriveFn),
+ ],
)
.expect("Failed to expand derive macros in test file");
}
diff --git a/crates/macros/src/php_clone.rs b/crates/macros/src/php_clone.rs
new file mode 100644
index 000000000..2ffdf7b84
--- /dev/null
+++ b/crates/macros/src/php_clone.rs
@@ -0,0 +1,39 @@
+//! Implementation for the `#[derive(PhpClone)]` macro.
+
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::DeriveInput;
+
+/// Parses the derive input and generates the trait implementations for
+/// cloneable PHP classes.
+pub fn parser(input: DeriveInput) -> TokenStream {
+ let DeriveInput { ident, .. } = input;
+
+ quote! {
+ impl ::ext_php_rs::convert::FromZendObject<'_> for #ident {
+ fn from_zend_object(
+ obj: &::ext_php_rs::types::ZendObject,
+ ) -> ::ext_php_rs::error::Result {
+ let class_obj =
+ ::ext_php_rs::types::ZendClassObject::<#ident>::from_zend_obj(obj)
+ .ok_or(::ext_php_rs::error::Error::ZendClassObjectExtraction)?;
+ ::ext_php_rs::error::Result::Ok((**class_obj).clone())
+ }
+ }
+
+ impl ::ext_php_rs::convert::FromZval<'_> for #ident {
+ const TYPE: ::ext_php_rs::flags::DataType = ::ext_php_rs::flags::DataType::Object(
+ ::std::option::Option::Some(
+ <#ident as ::ext_php_rs::class::RegisteredClass>::CLASS_NAME,
+ ),
+ );
+
+ fn from_zval(
+ zval: &::ext_php_rs::types::Zval,
+ ) -> ::std::option::Option {
+ let obj = zval.object()?;
+ ::from_zend_object(obj).ok()
+ }
+ }
+ }
+}
diff --git a/guide/src/macros/classes.md b/guide/src/macros/classes.md
index 8cc0b869a..4b0fe69c1 100644
--- a/guide/src/macros/classes.md
+++ b/guide/src/macros/classes.md
@@ -360,6 +360,120 @@ echo Counter::$count; // 2
echo Counter::getCount(); // 2
```
+## Using Classes as Properties
+
+By default, `#[php_class]` types cannot be used directly as properties of other
+`#[php_class]` types because they don't implement `FromZval`. To enable this,
+derive `PhpClone` on any class that needs to be used as a property.
+
+The class must implement `Clone`, and deriving `PhpClone` will implement
+`FromZval` and `FromZendObject` for the type, allowing PHP objects to be
+cloned into Rust values.
+
+```rust,ignore
+use ext_php_rs::prelude::*;
+
+// Inner class that will be used as a property
+#[php_class]
+#[derive(Clone, PhpClone)] // PhpClone enables use as a property
+pub struct Address {
+ #[php(prop)]
+ pub street: String,
+ #[php(prop)]
+ pub city: String,
+}
+
+#[php_impl]
+impl Address {
+ pub fn __construct(street: String, city: String) -> Self {
+ Self { street, city }
+ }
+}
+
+// Outer class containing the inner class as a property
+#[php_class]
+pub struct Person {
+ #[php(prop)]
+ pub name: String,
+ #[php(prop)]
+ pub address: Address, // Works because Address derives PhpClone
+}
+
+#[php_impl]
+impl Person {
+ pub fn __construct(name: String, address: Address) -> Self {
+ Self { name, address }
+ }
+
+ pub fn get_city(&self) -> String {
+ self.address.city.clone()
+ }
+}
+
+#[php_module]
+pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
+ module
+ .class::()
+ .class::()
+}
+```
+
+From PHP:
+
+```php
+name; // "John Doe"
+echo $person->address->city; // "Springfield"
+echo $person->getCity(); // "Springfield"
+
+// You can also set the nested property
+$newAddress = new Address("456 Oak Ave", "Shelbyville");
+$person->address = $newAddress;
+echo $person->address->city; // "Shelbyville"
+```
+
+### Clone Semantics
+
+When reading a property that uses `PhpClone`, PHP receives a **clone** of the
+Rust value. This has important implications:
+
+```php
+$address = new Address("123 Main St", "Springfield");
+$person = new Person("John Doe", $address);
+
+// Reading $person->address returns a CLONE
+$addressCopy = $person->address;
+$addressCopy->city = "Modified City";
+
+// The original is unchanged because $addressCopy is a clone
+echo $person->address->city; // Still "Springfield"
+
+// To modify the original, you must reassign the property
+$person->address = $addressCopy;
+echo $person->address->city; // Now "Modified City"
+```
+
+### Rc/Arc Considerations
+
+If your type contains `Rc`, `Arc`, or other reference-counted smart pointers,
+cloning will create a new handle that **shares** the underlying data with the
+original. This means mutations through the shared reference will affect both
+the original and the clone.
+
+**Important notes:**
+
+- The inner class must derive both `Clone` and `PhpClone`
+- When accessed from PHP, the property returns a clone of the Rust value
+- Modifications to the returned object don't affect the original unless reassigned
+- Types with `Rc`/`Arc` will share interior data after cloning
+
+See [GitHub issue #182](https://github.com/extphprs/ext-php-rs/issues/182)
+for more context.
+
## Abstract Classes
Abstract classes cannot be instantiated directly and may contain abstract methods
diff --git a/src/error.rs b/src/error.rs
index ab1345d29..7d65d0a52 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -74,6 +74,14 @@ pub enum Error {
SapiWriteUnavailable,
/// Failed to make an object lazy (PHP 8.4+)
LazyObjectFailed,
+ /// Failed to extract a [`ZendClassObject`] from a [`ZendObject`].
+ ///
+ /// This typically occurs when trying to convert a PHP object to a specific
+ /// Rust type, but the object is not an instance of the expected class.
+ ///
+ /// [`ZendClassObject`]: crate::types::ZendClassObject
+ /// [`ZendObject`]: crate::types::ZendObject
+ ZendClassObjectExtraction,
}
impl Display for Error {
@@ -123,6 +131,12 @@ impl Display for Error {
Error::LazyObjectFailed => {
write!(f, "Failed to make the object lazy")
}
+ Error::ZendClassObjectExtraction => {
+ write!(
+ f,
+ "Failed to extract ZendClassObject: object is not an instance of the expected class"
+ )
+ }
}
}
}
diff --git a/src/lib.rs b/src/lib.rs
index 5e2a83eb0..284fada77 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -76,8 +76,8 @@ pub mod prelude {
FcallInfo, FcallObserver,
};
pub use crate::{
- ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_impl_interface,
- php_interface, php_module, wrap_constant, wrap_function, zend_fastcall,
+ PhpClone, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl,
+ php_impl_interface, php_interface, php_module, wrap_constant, wrap_function, zend_fastcall,
};
}
@@ -108,6 +108,6 @@ pub const PHP_85: bool = cfg!(php85);
#[cfg(feature = "enum")]
pub use ext_php_rs_derive::php_enum;
pub use ext_php_rs_derive::{
- ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_impl_interface,
- php_interface, php_module, wrap_constant, wrap_function, zend_fastcall,
+ PhpClone, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl,
+ php_impl_interface, php_interface, php_module, wrap_constant, wrap_function, zend_fastcall,
};
diff --git a/src/macros.rs b/src/macros.rs
index b63166f33..93f301be5 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -333,6 +333,128 @@ macro_rules! class_derives {
};
}
+/// Derives additional traits for cloneable [`RegisteredClass`] types to enable
+/// using them as properties of other `#[php_class]` structs.
+///
+/// # Prefer `#[derive(PhpClone)]`
+///
+/// This macro is superseded by [`#[derive(PhpClone)]`](crate::PhpClone).
+/// The derive macro is more ergonomic and follows Rust conventions:
+///
+/// ```ignore
+/// use ext_php_rs::prelude::*;
+///
+/// #[php_class]
+/// #[derive(Clone, PhpClone)] // Preferred approach
+/// struct Bar {
+/// #[php(prop)]
+/// value: String,
+/// }
+/// ```
+///
+/// This macro is kept for backward compatibility.
+///
+/// ---
+///
+/// This macro should be called for any `#[php_class]` struct that:
+/// 1. Implements [`Clone`]
+/// 2. Needs to be used as a property in another `#[php_class]` struct
+///
+/// The macro implements [`FromZendObject`] and [`FromZval`] for the owned type,
+/// allowing PHP objects to be cloned into Rust values.
+///
+/// # Important: Clone Semantics
+///
+/// This macro creates a **clone** of the PHP object's underlying Rust data when
+/// reading the property. This has important implications:
+///
+/// - **Reading** the property returns a cloned copy of the data
+/// - **Writing** to the cloned object will NOT modify the original PHP object
+/// - Each read creates a new independent clone
+///
+/// If you need to modify the original object, you should use methods on the
+/// parent class that directly access the inner object, rather than reading
+/// the property and modifying the clone.
+///
+/// # Rc/Arc Considerations
+///
+/// If your type contains [`Rc`], [`Arc`], or other reference-counted smart
+/// pointers, be aware that cloning will create a new handle that shares the
+/// underlying data with the original. This means:
+///
+/// - Mutations through the shared reference WILL affect both the original and clone
+/// - The reference count will be incremented
+/// - This may lead to unexpected shared state between PHP objects
+///
+/// Consider using deep cloning strategies if you need complete isolation.
+///
+/// [`Rc`]: std::rc::Rc
+/// [`Arc`]: std::sync::Arc
+///
+/// # Example
+///
+/// ```ignore
+/// use ext_php_rs::prelude::*;
+/// use ext_php_rs::class_derives_clone;
+///
+/// #[php_class]
+/// #[derive(Clone)]
+/// struct Bar {
+/// #[php(prop)]
+/// value: String,
+/// }
+///
+/// class_derives_clone!(Bar);
+///
+/// #[php_class]
+/// struct Foo {
+/// #[php(prop)]
+/// bar: Bar, // Now works because Bar implements FromZval
+/// }
+/// ```
+///
+/// PHP usage demonstrating clone semantics:
+/// ```php
+/// $bar = new Bar("original");
+/// $foo = new Foo($bar);
+///
+/// // Reading $foo->bar returns a clone
+/// $barCopy = $foo->bar;
+/// $barCopy->value = "modified";
+///
+/// // Original is unchanged because $barCopy is a clone
+/// echo $foo->bar->value; // Outputs: "original"
+/// ```
+///
+/// See:
+///
+/// [`RegisteredClass`]: crate::class::RegisteredClass
+/// [`FromZendObject`]: crate::convert::FromZendObject
+/// [`FromZval`]: crate::convert::FromZval
+#[macro_export]
+macro_rules! class_derives_clone {
+ ($type: ty) => {
+ impl $crate::convert::FromZendObject<'_> for $type {
+ fn from_zend_object(obj: &$crate::types::ZendObject) -> $crate::error::Result {
+ let class_obj = $crate::types::ZendClassObject::<$type>::from_zend_obj(obj)
+ .ok_or($crate::error::Error::ZendClassObjectExtraction)?;
+ Ok((**class_obj).clone())
+ }
+ }
+
+ impl $crate::convert::FromZval<'_> for $type {
+ const TYPE: $crate::flags::DataType = $crate::flags::DataType::Object(Some(
+ <$type as $crate::class::RegisteredClass>::CLASS_NAME,
+ ));
+
+ fn from_zval(zval: &$crate::types::Zval) -> ::std::option::Option {
+ let obj = zval.object()?;
+ ::from_zend_object(obj).ok()
+ }
+ }
+ };
+}
+
/// Derives `From for Zval` and `IntoZval` for a given type.
macro_rules! into_zval {
($type: ty, $fn: ident, $dt: ident) => {
diff --git a/tests/src/integration/class/class.php b/tests/src/integration/class/class.php
index 165e0360f..507df7bdf 100644
--- a/tests/src/integration/class/class.php
+++ b/tests/src/integration/class/class.php
@@ -329,3 +329,55 @@ public function __construct(string $data) {
$childReflection = new ReflectionClass(TestChildClass::class);
assert($childReflection->getParentClass()->getName() === TestBaseClass::class, 'TestChildClass should extend TestBaseClass');
assert($childObj instanceof TestBaseClass, 'TestChildClass instance should be instanceof TestBaseClass');
+
+// Test issue #182 - class structs containing class struct properties
+$inner = new InnerClass('hello world');
+assert($inner->getValue() === 'hello world', 'InnerClass getValue should work');
+assert($inner->value === 'hello world', 'InnerClass property should be accessible');
+
+$outer = new OuterClass($inner);
+assert($outer->getInnerValue() === 'hello world', 'OuterClass should be able to access inner value');
+
+// Test that the inner property is properly accessible
+assert($outer->inner instanceof InnerClass, 'outer->inner should be InnerClass instance');
+assert($outer->inner->value === 'hello world', 'outer->inner->value should be accessible');
+
+// Test setting inner property
+$newInner = new InnerClass('new value');
+$outer->inner = $newInner;
+assert($outer->getInnerValue() === 'new value', 'After setting inner, value should be updated');
+
+// Test clone semantics - reading returns a clone, writes don't affect original
+// This is important behavior to understand when using #[derive(PhpClone)]
+$inner2 = new InnerClass('clone test');
+$outer2 = new OuterClass($inner2);
+
+// Verify initial state
+assert($outer2->inner->value === 'clone test', 'Initial inner value should be "clone test"');
+
+// Get a reference to inner - this is actually a CLONE
+$innerCopy = $outer2->inner;
+assert($innerCopy instanceof InnerClass, 'innerCopy should be an InnerClass instance');
+assert($innerCopy->value === 'clone test', 'innerCopy should have the original value');
+
+// Modify the copy's value
+$innerCopy->value = 'modified by copy';
+assert($innerCopy->value === 'modified by copy', 'innerCopy should have modified value');
+
+// The ORIGINAL should be unchanged because innerCopy was a clone
+assert($outer2->inner->value === 'clone test', 'Original inner value should be unchanged after modifying copy');
+assert($outer2->getInnerValue() === 'clone test', 'getInnerValue should still return original value');
+
+// Verify that each read creates a new clone
+$copy1 = $outer2->inner;
+$copy2 = $outer2->inner;
+$copy1->value = 'copy1 modified';
+assert($copy1->value === 'copy1 modified', 'copy1 should have modified value');
+assert($copy2->value === 'clone test', 'copy2 should still have original value (independent clone)');
+assert($outer2->inner->value === 'clone test', 'Original should still be unchanged');
+
+// To actually modify the inner object, we need to set the whole property
+$modifiedInner = new InnerClass('actually modified');
+$outer2->inner = $modifiedInner;
+assert($outer2->inner->value === 'actually modified', 'After setting property, value should be updated');
+assert($outer2->getInnerValue() === 'actually modified', 'getInnerValue should return new value');
diff --git a/tests/src/integration/class/mod.rs b/tests/src/integration/class/mod.rs
index d1863cc46..e954067a3 100644
--- a/tests/src/integration/class/mod.rs
+++ b/tests/src/integration/class/mod.rs
@@ -568,6 +568,45 @@ impl TestChildClass {
}
}
+// Test for issue #182 - class structs containing class struct properties
+// The inner class must derive Clone and PhpClone
+
+#[php_class]
+#[derive(Clone, Default, PhpClone)]
+pub struct InnerClass {
+ #[php(prop)]
+ pub value: String,
+}
+
+#[php_impl]
+impl InnerClass {
+ pub fn __construct(value: String) -> Self {
+ Self { value }
+ }
+
+ pub fn get_value(&self) -> String {
+ self.value.clone()
+ }
+}
+
+#[php_class]
+#[derive(Default)]
+pub struct OuterClass {
+ #[php(prop)]
+ pub inner: InnerClass,
+}
+
+#[php_impl]
+impl OuterClass {
+ pub fn __construct(inner: InnerClass) -> Self {
+ Self { inner }
+ }
+
+ pub fn get_inner_value(&self) -> String {
+ self.inner.value.clone()
+ }
+}
+
pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
let builder = builder
.class::()
@@ -586,6 +625,8 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
.class::()
.class::()
.class::()
+ .class::()
+ .class::()
.function(wrap_function!(test_class))
.function(wrap_function!(throw_exception));