diff --git a/composer.json b/composer.json
index 8fd7615..47504e4 100644
--- a/composer.json
+++ b/composer.json
@@ -48,6 +48,7 @@
"horde/token": "^3 || dev-FRAMEWORK_6_0",
"horde/translation": "^3 || dev-FRAMEWORK_6_0",
"horde/util": "^3 || dev-FRAMEWORK_6_0",
+ "psr/http-message": "^2",
"ext-json": "*"
},
"require-dev": {
diff --git a/doc/UPGRADING.md b/doc/UPGRADING.md
index 6203dac..527b158 100644
--- a/doc/UPGRADING.md
+++ b/doc/UPGRADING.md
@@ -1118,7 +1118,117 @@ transparently across the form API:
---
-## Known limitations (2026-04)
+## File uploads via PSR-7
+
+### Overview
+
+When a `ServerRequestInterface` is passed to BaseForm's constructor,
+uploaded files are extracted automatically via `getUploadedFiles()` and
+injected into `FileVariable` and `ImageVariable` instances before
+`validate()` and `getInfo()` run. No `$_FILES` access or
+`$GLOBALS['browser']` dependency is needed on the PSR-7 path.
+
+### Usage
+
+```php
+use Horde\Form\V3\BaseForm;
+
+// PSR-7 request from your middleware / router
+$form = new BaseForm($request, 'Upload Document');
+$form->addVariable('Title', 'title', 'text', true);
+$form->addVariable('Document', 'document', 'file', true);
+
+if ($form->validate()) {
+ $info = $form->getInfo();
+ // $info['document'] contains:
+ // 'name' => 'report.pdf' (client filename)
+ // 'type' => 'application/pdf' (client media type)
+ // 'size' => 45678 (bytes)
+ // 'tmp_name' => '/tmp/horde_form_upload_abc123' (on-disk temp file)
+ // 'file' => same as tmp_name
+ // 'error' => UPLOAD_ERR_OK
+ // 'uploaded_file' => UploadedFileInterface instance
+ rename($info['document']['tmp_name'], $permanentPath);
+}
+```
+
+### Important: move_uploaded_file() does NOT work
+
+The temp file is created by writing the PSR-7 stream to disk. PHP's
+`move_uploaded_file()` rejects it because PHP did not create it via
+its upload mechanism. Use `rename()` or the `UploadedFileInterface`
+object's `moveTo()` method instead:
+
+```php
+// Option A: rename (simple, works on same filesystem)
+rename($info['document']['tmp_name'], $dest);
+
+// Option B: PSR-7 moveTo (works cross-filesystem, preferred)
+$info['document']['uploaded_file']->moveTo($dest);
+```
+
+### Legacy fallback
+
+When form data is provided as an array or `Horde_Variables` (i.e., not
+a full PSR-7 request), no uploaded files are extracted from the request.
+`FileVariable` and `ImageVariable` fall back to reading `$_FILES` and
+using `$GLOBALS['browser']->wasFileUploaded()`. Existing code using
+this pattern is unchanged.
+
+### Explicit file injection
+
+When you decompose the PSR-7 request before passing form data:
+
+```php
+$formVars = $request->getParsedBody();
+$form = new BaseForm($formVars, 'Upload');
+$form->setUploadedFiles($request->getUploadedFiles());
+```
+
+This is equivalent to passing the full request.
+
+### Custom file-type variables
+
+To create a custom variable type that participates in PSR-7 file
+injection, implement `FileUploadAware`:
+
+```php
+use Horde\Form\V3\BaseVariable;
+use Horde\Form\V3\FileUploadAware;
+use Psr\Http\Message\UploadedFileInterface;
+
+class AvatarVariable extends BaseVariable implements FileUploadAware
+{
+ private ?UploadedFileInterface $uploadedFile = null;
+
+ public function setUploadedFile(?UploadedFileInterface $file): void
+ {
+ $this->uploadedFile = $file;
+ }
+
+ protected function isValid($vars, $value): bool
+ {
+ if ($this->uploadedFile !== null) {
+ if ($this->uploadedFile->getError() !== UPLOAD_ERR_OK) {
+ return $this->invalid('Upload failed.');
+ }
+ if ($this->uploadedFile->getSize() > 2_000_000) {
+ return $this->invalid('Avatar must be under 2MB.');
+ }
+ return true;
+ }
+ // Legacy fallback...
+ }
+}
+```
+
+BaseForm resolves the upload by matching the variable's name against the
+request's uploaded files tree. For `ImageVariable`, the key is
+`{varname}[new]` to match the image upload widget's HTML structure.
+
+---
+
+## Known limitations (2026-05)
- **Sub-forms**: Replaced by `FieldGroup` / `Section` model (see
"FieldGroup and Section" below). Groups provide structural variable
@@ -1131,8 +1241,6 @@ transparently across the form API:
future scope item.
- **Asset collection**: `AssetManager` collects JS/CSS paths but has
no integration with Horde's page output yet.
-- **File uploads**: `renderFile()` produces ``
- but file processing in `getInfo()` is not fully wired.
- **validate() / getInfo() still wrap Horde_Variables**: These
submission-time paths still construct `new Horde_Variables($array)`.
Rendering paths have been migrated to `resolveValue()`.
diff --git a/doc/V3-MIGRATION-GUIDE.md b/doc/V3-MIGRATION-GUIDE.md
deleted file mode 100644
index 9ca579f..0000000
--- a/doc/V3-MIGRATION-GUIDE.md
+++ /dev/null
@@ -1,798 +0,0 @@
-# Horde_Form: lib/ vs src/V3 Developer Guide
-
-> **Superseded**: This document was written 2026-03-05 when BaseForm,
-> Actions, and Renderer were not yet implemented. All three now exist.
-> See **[UPGRADING.md](UPGRADING.md)** for the current migration guide.
-> This file is kept for historical reference only.
-
-**Version**: 3.0.0-beta3+
-**Date**: 2026-03-05
-**Audience**: Horde application developers
-**Status**: OUTDATED — BaseForm, Actions, and Renderer are now implemented
-
----
-
-## Quick Decision Guide
-
-**Should I use lib/ or src/V3?**
-
-```
-┌─────────────────────────────────────────────────────────────┐
-│ Are you maintaining an existing Horde 5/6 application? │
-│ ├─ YES → Use lib/ (legacy) │
-│ └─ NO → Continue ↓ │
-│ │
-│ Do you need Actions (reload, submit, etc)? │
-│ ├─ YES → Use lib/ (V3 Actions not ported yet) │
-│ └─ NO → Continue ↓ │
-│ │
-│ Do you need custom Renderer? │
-│ ├─ YES → Use lib/ (V3 Renderer not ported yet) │
-│ └─ NO → Continue ↓ │
-│ │
-│ Are you starting a NEW application from scratch? │
-│ ├─ YES → Use V3 (modern, future-proof) │
-│ └─ NO → Use lib/ (safer for existing code) │
-└─────────────────────────────────────────────────────────────┘
-```
-
-**Summary**: Use **lib/** for existing/legacy apps. Use **V3** for new greenfield projects that don't need Actions/Renderer yet.
-
----
-
-## Overview
-
-Horde_Form has two parallel implementations:
-
-1. **lib/** - Legacy implementation (Horde 5/6 era)
- - Complete: Form, Variable, Type, Action, Renderer
- - Ancient patterns: Reference passing, no type hints
- - Stable, widely used, will be maintained through H6
-
-2. **src/V3/** - Modern implementation (Horde 7+ future)
- - Incomplete: BaseVariable ✅, BaseForm ❌, Actions ❌, Renderer ❌
- - Modern patterns: Type hints, namespaces, no references
- - Under development, breaking changes possible
-
-**Important**: These implementations **cannot be mixed** in the same form. Choose one or the other.
-
----
-
-## When to Use lib/ (Legacy)
-
-### ✅ Use lib/ When:
-
-1. **Maintaining existing applications**
- ```php
- // Existing Horde app code
- $form = new Horde_Form($vars, 'Edit Task');
- $form->addVariable('Name', 'name', 'text', true);
- ```
-
-2. **Need Actions system**
- ```php
- // Actions not in V3 yet
- $var->setAction(Horde_Form_Action::factory('reload'));
- ```
-
-3. **Need Renderer system**
- ```php
- // Renderer not in V3 yet
- $renderer = $form->getRenderer();
- $renderer->render();
- ```
-
-4. **Mixed with other lib/-style code**
- ```php
- // Your app uses lib/ Horde_Form elsewhere
- // Keep consistency
- ```
-
-5. **Need stability for production**
- - lib/ is complete, tested, stable
- - V3 is under development
-
-### ❌ Don't Use lib/ For:
-
-1. **New greenfield applications** (use V3 if possible)
-2. **Code that will need PHP 9+ support** (references deprecated)
-3. **Code requiring modern IDE support** (no type hints in lib/)
-
----
-
-## When to Use src/V3/ (Modern)
-
-### ✅ Use V3 When:
-
-1. **Starting new applications from scratch**
- ```php
- // New app, no legacy code
- use Horde\Form\V3\BaseForm;
- $form = new BaseForm($vars, 'New Form');
- ```
-
-2. **Full application conversions**
- - Converting entire app from lib/ to V3
- - All-or-nothing per form
-
-3. **Want modern PHP patterns**
- ```php
- // Type hints, return types
- public function validate(): bool { }
- public function getInfo($vars = null): array { }
- ```
-
-4. **Don't need Actions/Renderer yet**
- - Simple forms with validation only
- - Can wait for Actions/Renderer porting
-
-5. **Want future-proof code**
- - V3 is the direction for Horde 7+
- - lib/ will be deprecated eventually
-
-### ❌ Don't Use V3 For:
-
-1. **Production apps** (V3 BaseForm not complete yet)
-2. **Forms needing Actions** (not ported yet)
-3. **Forms needing custom Renderer** (not ported yet)
-4. **Mixed with lib/ code** (incompatible interfaces)
-
----
-
-## Migration Path: lib/ → V3
-
-### Can I Mix lib/ and V3?
-
-**NO.** lib/ and V3 have incompatible interfaces and cannot be mixed in the same form.
-
-**Incompatibilities:**
-
-| Aspect | lib/ | V3 | Compatible? |
-|--------|------|-----|-------------|
-| Variable class | `Horde_Form_Variable` | `Horde\Form\V3\Variable` | ❌ No |
-| Type classes | Separate `Horde_Form_Type_*` | Merged into `*Variable` | ❌ No |
-| Validation signature | `isValid($var, $vars, $value, $message)` | `isValid($vars, $value): bool` | ❌ No |
-| Error handling | `$message` by reference | `invalid()` method | ❌ No |
-| getInfo() | Modifies `&$info` parameter | Returns array | ❌ No |
-
-### Migration is Per-Form, All-or-Nothing
-
-**Option 1: Keep lib/ (no changes needed)**
-```php
-// Existing code - works fine
-$form = new Horde_Form($vars, 'Edit Task');
-$form->addVariable('Name', 'name', 'text', true);
-$form->addVariable('Email', 'email', 'email', true);
-if ($form->validate()) {
- $form->getInfo($vars, $info);
-}
-```
-
-**Option 2: Convert to V3 (full rewrite)**
-```php
-// V3 version - when BaseForm is ready
-use Horde\Form\V3\BaseForm;
-$form = new BaseForm($vars, 'Edit Task');
-$form->addVariable('Name', 'name', 'text', true);
-$form->addVariable('Email', 'email', 'email', true);
-if ($form->validate()) {
- $info = $form->getInfo($vars); // Returns array!
-}
-```
-
-**You cannot do this:**
-```php
-// ❌ WRONG - mixing lib/ Form with V3 Variable
-$form = new Horde_Form($vars); // lib/
-$var = new Horde\Form\V3\TextVariable('Name', 'name', true); // V3
-$form->addVariable($var); // ERROR: Incompatible!
-```
-
-### Migration Steps (When V3 BaseForm is Ready)
-
-1. **Identify forms to migrate**
- - Start with simple forms (no Actions, no custom Renderer)
- - Leave complex forms on lib/ for now
-
-2. **Update class names**
- ```php
- // Before
- use Horde_Form;
- $form = new Horde_Form($vars, 'Title');
-
- // After
- use Horde\Form\V3\BaseForm;
- $form = new BaseForm($vars, 'Title');
- ```
-
-3. **Update getInfo() calls**
- ```php
- // Before (lib/)
- $form->getInfo($vars, $info);
- // $info is modified by reference
-
- // After (V3)
- $info = $form->getInfo($vars);
- // Returns array
- ```
-
-4. **Test thoroughly**
- - V3 validation is different internally
- - Error messages may differ
- - Behavior should match but verify
-
-5. **Keep Actions/Renderer on lib/ for now**
- - Only migrate simple forms to V3
- - Complex forms stay on lib/ until V3 Actions/Renderer ready
-
----
-
-## Architecture Comparison
-
-### lib/ (Legacy) Architecture
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ Horde_Form │
-│ - Constructor: __construct($vars, $title, $name) │
-│ - addVariable(...) → creates Horde_Form_Variable │
-│ - validate() → calls Variable::validate() │
-│ - getInfo() → extracts values │
-└─────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────┐
-│ Horde_Form_Variable │
-│ - Constructor: __construct($name, $varName, $type, ...)│
-│ - type: Horde_Form_Type object (separate) │
-│ - validate() → calls $this->type->isValid() │
-└─────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────┐
-│ Horde_Form_Type_text (etc) │
-│ - init($regex, $size, $maxlength) │
-│ - isValid($var, $vars, $value, $message) │
-│ - $message passed by REFERENCE (modified) │
-└─────────────────────────────────────────────────────────┘
-```
-
-**Key characteristics:**
-- **3 objects**: Form, Variable, Type (separate)
-- **Reference passing**: `$this->_vars = &$vars`, `$message` by reference
-- **No type hints**: Dynamic typing throughout
-- **Ancient patterns**: Singleton, global namespace
-
-### V3 (Modern) Architecture
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ Horde\Form\V3\BaseForm │
-│ - Constructor: __construct($vars, string $title, ...) │
-│ - addVariable(...): Variable → creates *Variable │
-│ - validate(): bool → typed return │
-│ - getInfo($vars = null): array → returns array │
-└─────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────┐
-│ Horde\Form\V3\TextVariable │
-│ - Extends: BaseVariable │
-│ - NO separate Type object (merged!) │
-│ - init(...$params) → variadic │
-│ - validate($vars): bool → typed return │
-│ - isValid($vars, $value): bool → internal │
-│ - invalid($message): bool → error handling │
-└─────────────────────────────────────────────────────────┘
-```
-
-**Key characteristics:**
-- **2 objects**: Form, Variable (Type merged into Variable)
-- **No reference parameters**: Clean signatures
-- **Type hints everywhere**: `bool`, `array`, `string`
-- **Modern patterns**: Namespaces, PSR-4
-
----
-
-## Breaking Changes: lib/ → V3
-
-### 1. Type/Variable Merge
-
-**lib/ (separate):**
-```php
-$var = new Horde_Form_Variable('Name', 'name', $type, true);
-$type = $var->getType(); // Returns Horde_Form_Type_text
-$type->isValid($var, $vars, $value, $message);
-```
-
-**V3 (merged):**
-```php
-$var = new TextVariable('Name', 'name', true);
-// No separate Type object!
-$var->validate($vars); // Variable IS the type
-$var->getType(); // Returns $this (with deprecation warning)
-```
-
-**Impact**: Internal only - `addVariable()` API stays the same.
-
-### 2. Validation Signature
-
-**lib/:**
-```php
-// In Horde_Form_Type
-public function isValid($var, $vars, $value, $message)
-{
- // Modify $message by reference
- $this->message = 'Error';
- return false;
-}
-```
-
-**V3:**
-```php
-// In *Variable
-protected function isValid(Horde_Variables $vars, $value): bool
-{
- // Use invalid() method
- return $this->invalid('Error message');
-}
-```
-
-**Impact**: Custom type implementations need rewrite.
-
-### 3. getInfo() Signature
-
-**lib/:**
-```php
-$form->getInfo($vars, $info);
-// $info modified by reference
-echo $info['name'];
-```
-
-**V3:**
-```php
-$info = $form->getInfo($vars);
-// Returns array
-echo $info['name'];
-```
-
-**Impact**: All getInfo() calls need update.
-
-### 4. Error Handling
-
-**lib/:**
-```php
-$var->type->isValid($var, $vars, $value, $message);
-// $message set by reference
-echo $message;
-```
-
-**V3:**
-```php
-$var->validate($vars);
-echo $var->getMessage(); // Getter instead
-```
-
-**Impact**: Error retrieval pattern changes.
-
-### 5. Validation Method Parameter
-
-**lib/:**
-```php
-// validate() takes $message parameter
-$var->validate($vars, $message);
-```
-
-**V3:**
-```php
-// validate() no longer takes $message
-$var->validate($vars); // Deprecation warning if you pass $message
-```
-
-**Impact**: Remove $message parameter from all validate() calls.
-
----
-
-## Gotchas: Odd lib/ Behavior in PHP 8.4+
-
-### 1. Non-Static Singleton Called Statically
-
-**Problem:**
-```php
-// In Horde_Form_Action
-public function singleton($action) // Non-static method
-{
- static $instances = [];
- // ...
-}
-
-// Called statically
-$action = Horde_Form_Action::singleton('reload'); // ERROR in PHP 8.4
-```
-
-**Error**: "Non-static method Horde_Form_Action::singleton() cannot be called statically"
-
-**Workaround**: Use `factory()` instead:
-```php
-$action = Horde_Form_Action::factory('reload'); // Works
-```
-
-**V3 Fix**: Remove singleton pattern entirely.
-
-### 2. Reference Passing Deprecated
-
-**Problem:**
-```php
-// lib/ uses references everywhere
-$this->_vars = &$vars;
-$this->_hiddenVariables[] = &$var;
-public function getInfo($vars, &$info) { }
-```
-
-**Issue**: Reference passing may be deprecated in PHP 9+
-
-**Workaround**: None for lib/, avoid in new code
-
-**V3 Fix**: Minimal reference usage, return values instead
-
-### 3. No Type Hints = No IDE Support
-
-**Problem:**
-```php
-// lib/ has no type hints
-public function validate($vars) { }
-public function getInfo($vars, $info) { }
-```
-
-**Issue**:
-- No IDE autocomplete
-- No static analysis
-- Hard to refactor
-
-**V3 Fix**: Full type hints everywhere
-```php
-public function validate($vars): bool { }
-public function getInfo($vars = null): array { }
-```
-
-### 4. $message Reference Parameter
-
-**Problem:**
-```php
-// Easy to forget to pass by reference
-$type->isValid($var, $vars, $value, $message); // $message not declared
-// $message stays empty, no error shown!
-```
-
-**Workaround**: Always declare `$message = ''` before calling
-
-**V3 Fix**: No $message parameter, use `invalid()` method
-
-### 5. Polymorphic Signatures
-
-**Problem:**
-```php
-// addVariable has 4-7 parameters depending on usage
-public function addVariable($humanName, $varName, $type, $required,
- $readonly = false, $description = null, $params = [])
-```
-
-**Issue**: Easy to pass wrong number/order of parameters
-
-**V3 Fix**: Same signature but with type hints for safety
-
----
-
-## API Compatibility Matrix
-
-### Form API
-
-| Method | lib/ Signature | V3 Signature | Compatible? |
-|--------|----------------|--------------|-------------|
-| Constructor | `__construct($vars, $title, $name)` | `__construct($vars, string $title, ?string $name)` | ✅ Yes (types added) |
-| addVariable | `addVariable(...)` → `Horde_Form_Variable` | `addVariable(...): Variable` | ✅ Yes (return type) |
-| validate | `validate()` → bool | `validate(): bool` | ✅ Yes (type hint) |
-| getInfo | `getInfo($vars, &$info)` | `getInfo($vars = null): array` | ❌ **NO** (signature change) |
-| setError | `setError($var, $message)` | `setError($var, string $message)` | ✅ Yes (type hint) |
-| getError | `getError($var)` | `getError($var): ?string` | ✅ Yes (type hint) |
-
-### Variable API
-
-| Method | lib/ Signature | V3 Signature | Compatible? |
-|--------|----------------|--------------|-------------|
-| Constructor | `__construct($name, $varName, $type, ...)` | `__construct($name, $varName, $required, ...)` | ❌ **NO** (no $type param) |
-| validate | `validate($vars, $message)` | `validate($vars): bool` | ❌ **NO** ($message removed) |
-| getValue | `getValue($vars, $index)` | `getValue($vars, $index = null)` | ✅ Yes (default added) |
-| getType | `getType()` → `Horde_Form_Type` | `getType()` → `$this` | ⚠️ Different (deprecation) |
-| getMessage | N/A | `getMessage(): string` | ➕ New in V3 |
-
-### Type API
-
-| Method | lib/ Signature | V3 Signature | Compatible? |
-|--------|----------------|--------------|-------------|
-| init | `init(...$params)` | `init(...$params)` | ✅ Yes |
-| isValid | `isValid($var, $vars, $value, $message)` | `isValid($vars, $value): bool` | ❌ **NO** (parameters) |
-| getInfo | `getInfo($vars, $var, &$info)` | `getInfoV3($vars): mixed` | ❌ **NO** (signature) |
-| about | `about()` → array | `about(): array` | ✅ Yes (type hint) |
-
-**Legend:**
-- ✅ Compatible (works with type hints added)
-- ⚠️ Different (works but behavior differs)
-- ❌ **NO** (breaking change, incompatible)
-- ➕ New (V3 addition)
-
----
-
-## Real-World Examples
-
-### Example 1: Simple Form (Both APIs)
-
-**lib/ version:**
-```php
-addVariable('Your Name', 'name', 'text', true);
-$form->addVariable('Email', 'email', 'email', true);
-$form->addVariable('Message', 'message', 'longtext', true);
-
-if ($form->validate()) {
- $form->getInfo($vars, $info);
- // Process $info
- sendEmail($info['name'], $info['email'], $info['message']);
-}
-
-$renderer = $form->getRenderer();
-$renderer->render();
-```
-
-**V3 version (when BaseForm ready):**
-```php
-addVariable('Your Name', 'name', 'text', true);
-$form->addVariable('Email', 'email', 'email', true);
-$form->addVariable('Message', 'message', 'longtext', true);
-
-if ($form->validate()) {
- $info = $form->getInfo($vars); // Returns array!
- sendEmail($info['name'], $info['email'], $info['message']);
-}
-
-// Renderer not available in V3 yet
-// Manual rendering or wait for V3 Renderer
-```
-
-**Key differences:**
-1. Import statement: `use Horde\Form\V3\BaseForm`
-2. getInfo() returns array: `$info = $form->getInfo($vars)`
-3. No Renderer in V3 yet
-
-### Example 2: Form with Actions (lib/ only)
-
-```php
-addVariable(
- 'Category',
- 'category',
- 'enum',
- true,
- false,
- null,
- [['tech' => 'Technology', 'science' => 'Science']]
-);
-
-// Reload form when category changes
-$categoryVar->setAction(Horde_Form_Action::factory('reload'));
-
-// Conditional field (shown based on category)
-if ($vars->get('category') == 'tech') {
- $form->addVariable('Technology Type', 'tech_type', 'enum', true, false, null, [
- ['web' => 'Web', 'mobile' => 'Mobile']
- ]);
-}
-
-if ($form->validate()) {
- $form->getInfo($vars, $info);
- processForm($info);
-}
-```
-
-**Cannot do in V3 yet** - Actions not ported.
-
-### Example 3: Custom Variable Type
-
-**lib/ custom type:**
-```php
-_country = $country;
- }
-
- public function isValid($var, $vars, $value, $message)
- {
- if ($var->isRequired() && empty($value)) {
- $this->message = 'ZIP code is required';
- return false;
- }
-
- if ($this->_country == 'US' && !preg_match('/^\d{5}(-\d{4})?$/', $value)) {
- $this->message = 'Invalid US ZIP code';
- return false;
- }
-
- return true;
- }
-
- public function about()
- {
- return [
- 'name' => 'ZIP Code',
- 'params' => [
- 'country' => ['label' => 'Country', 'type' => 'text']
- ]
- ];
- }
-}
-
-// Usage
-$form->addVariable('ZIP Code', 'zipcode', 'customapp:zipcode', true, false, null, ['US']);
-```
-
-**V3 custom type (when BaseForm ready):**
-```php
-_country = $params[0] ?? 'US';
- }
-
- protected function isValid(Horde_Variables $vars, $value): bool
- {
- if ($this->isRequired() && empty($value)) {
- return $this->invalid('ZIP code is required');
- }
-
- if ($this->_country == 'US' && !preg_match('/^\d{5}(-\d{4})?$/', $value)) {
- return $this->invalid('Invalid US ZIP code');
- }
-
- return true;
- }
-
- public function about(): array
- {
- return [
- 'name' => 'ZIP Code',
- 'params' => [
- 'country' => ['label' => 'Country', 'type' => 'text']
- ]
- ];
- }
-}
-
-// Usage
-$form->addVariable('ZIP Code', 'zipcode', 'customapp:zipcode', true, false, null, ['US']);
-```
-
-**Key differences:**
-1. Namespace: `CustomApp\Form\V3`
-2. Extends: `BaseVariable` (not separate Type)
-3. Signature: `isValid($vars, $value): bool` (typed)
-4. Error: `$this->invalid('message')` (not $message reference)
-5. Return type: `about(): array` (typed)
-
----
-
-## FAQ
-
-### Q: When will V3 be complete?
-
-**A**: Unknown. BaseForm, Actions, and Renderer need implementation. Estimated 4-6 weeks of development work. No committed timeline.
-
-### Q: Will lib/ be removed?
-
-**A**: Not in Horde 6. lib/ will be maintained through H6 lifecycle. May be deprecated in Horde 7+ once V3 is complete and stable.
-
-### Q: Can I start using V3 now?
-
-**A**: Only for experimental/testing. BaseForm is not implemented yet. Wait for 3.0.0 stable release.
-
-### Q: How do I test my app with both lib/ and V3?
-
-**A**: You can't mix them in the same form. You'd need to maintain two versions of each form and test separately.
-
-### Q: What if I have a custom Renderer?
-
-**A**: Use lib/. V3 Renderer not started yet. Custom renderers will need to be rewritten for V3 when available.
-
-### Q: Will my lib/ code break in PHP 9?
-
-**A**: Maybe. Reference passing and non-static methods called statically may cause issues. Plan to migrate to V3 before PHP 9.
-
-### Q: Where can I see V3 examples?
-
-**A**: Test files in `test/v3/` show V3 usage patterns. Real apps will need to wait for BaseForm implementation.
-
-### Q: How do I report V3 bugs?
-
-**A**: GitHub issues at https://github.com/horde/Form/issues - mark with "V3" label.
-
-### Q: Can I contribute to V3?
-
-**A**: Yes! BaseForm implementation is straightforward. See `horde-development/horde-form-v3-completeness-analysis.md` for implementation guide.
-
----
-
-## Version History
-
-| Version | Date | Changes |
-|---------|------|---------|
-| 3.0.0-beta3 | 2025-07-05 | V3 Variables complete, BaseForm stub |
-| 3.0.0-beta2 | 2025-07-02 | V3 type initializer fixes |
-| 3.0.0-beta1 | 2025-07-02 | Initial V3 namespace |
-| 3.0.0-alpha8 | 2025-06-22 | V3 design start |
-| 2.0.19 | 2019-01-06 | Last stable lib/ release |
-
----
-
-## Summary
-
-**Use lib/** for:
-- ✅ Existing/maintenance work
-- ✅ Production applications
-- ✅ Forms with Actions
-- ✅ Forms with custom Renderers
-- ✅ Stability required
-
-**Use V3** for:
-- ✅ New greenfield apps (when BaseForm ready)
-- ✅ Future-proof code
-- ✅ Modern PHP patterns
-- ⏳ Wait for 3.0.0 stable release
-
-**Cannot mix** lib/ and V3 in same form - choose one or the other.
-
-**Migration** will be all-or-nothing per form when V3 is complete.
-
----
-
-## Additional Resources
-
-- **V3 Completeness Analysis**: `horde-development/horde-form-v3-completeness-analysis.md`
-- **V3 Test Coverage**: `horde-development/horde-form-v3-test-coverage.md`
-- **V3 vs lib/ Comparison**: `horde-development/horde-form-v3-analysis.md`
-- **GitHub Issues**: https://github.com/horde/Form/issues
-- **Horde Documentation**: https://www.horde.org/libraries/Horde_Form
-
----
-
-**Document Status**: Complete, ready for review
-**Next Steps**: Implement BaseForm (issue #19), update this doc when complete
diff --git a/src/V3/BaseForm.php b/src/V3/BaseForm.php
index 283fc44..ffb4d60 100644
--- a/src/V3/BaseForm.php
+++ b/src/V3/BaseForm.php
@@ -24,25 +24,41 @@
use Horde\Token\Token;
use Horde\Token\Exception\TokenException;
use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\UploadedFileInterface;
/**
* Base implementation of the Form interface for Horde Form V3.
*
* This is a modernized implementation of Horde_Form with:
* - Type/Variable merge (no separate Type objects)
- * - PSR-7 ServerRequest support
+ * - PSR-7 ServerRequest support (including file uploads)
* - Named parameters throughout
* - Strict typing
* - Modern PHP patterns (no singleton, minimal reference passing)
*
+ * ## Input types
+ *
* V3 accepts multiple input types for backward compatibility:
* - Horde_Variables (legacy Horde apps)
- * - PSR-7 ServerRequest (modern apps)
+ * - PSR-7 ServerRequest (modern apps — recommended)
* - Plain arrays (testing, simple apps)
*
* All inputs are normalized to array internally for consistent operation.
*
- * PSR-4 implementation.
+ * ## File uploads
+ *
+ * When a PSR-7 ServerRequestInterface is passed to the constructor,
+ * uploaded files are extracted via getUploadedFiles() and stored
+ * internally. During validate() and getInfo(), any variable implementing
+ * FileUploadAware receives the corresponding UploadedFileInterface
+ * before its validation or extraction logic runs.
+ *
+ * This eliminates the need for $_FILES and $GLOBALS['browser'] when
+ * using the PSR-7 input path. The legacy fallback remains active when
+ * form data is provided as an array or Horde_Variables.
+ *
+ * Files can also be set explicitly via setUploadedFiles() when the
+ * request was decomposed before reaching the form.
*
* @see Horde_Form PSR-0 legacy equivalent in lib/Horde/Form.php
*
@@ -82,6 +98,13 @@ class BaseForm implements \Horde\Form\Form
*/
private array $vars;
+ /**
+ * Uploaded files from PSR-7 request.
+ *
+ * @var array
+ */
+ private array $uploadedFiles = [];
+
/**
* Submit button labels.
*
@@ -211,7 +234,9 @@ private function normalizeVars(
if (is_array($vars)) {
return $vars;
}
- // PSR-7: read from the source matching the HTTP method.
+ // PSR-7: extract uploaded files alongside form data.
+ $this->uploadedFiles = $vars->getUploadedFiles();
+ // Read from the source matching the HTTP method.
// POST/PUT/PATCH carry data in the body; GET/DELETE/HEAD in query.
return match ($vars->getMethod()) {
'POST', 'PUT', 'PATCH' => $vars->getParsedBody() ?? [],
@@ -247,6 +272,66 @@ public function setVars(Horde_Variables|ServerRequestInterface|array $vars): voi
$this->vars = $this->normalizeVars($vars);
}
+ /**
+ * Set uploaded files for file-type form variables.
+ *
+ * When a PSR-7 ServerRequestInterface is passed to the constructor,
+ * uploaded files are extracted automatically. Use this method when
+ * form data is provided as an array or Horde_Variables but uploaded
+ * files are available separately (e.g., from a PSR-7 request that
+ * was decomposed before reaching the form).
+ *
+ * The array structure mirrors ServerRequestInterface::getUploadedFiles():
+ * keys are field names, values are UploadedFileInterface instances or
+ * nested arrays thereof.
+ *
+ * @param array $uploadedFiles
+ *
+ * @api
+ */
+ public function setUploadedFiles(array $uploadedFiles): void
+ {
+ $this->uploadedFiles = $uploadedFiles;
+ }
+
+ /**
+ * Resolve the uploaded file for a given variable name.
+ *
+ * Navigates the uploadedFiles tree using bracket notation:
+ * - Simple: "photo" → $uploadedFiles['photo']
+ * - Nested: "object[photo][new]" → $uploadedFiles['object']['photo']['new']
+ */
+ private function getUploadedFileFor(Variable $var): ?UploadedFileInterface
+ {
+ if (empty($this->uploadedFiles)) {
+ return null;
+ }
+
+ $name = $var->getVarName();
+
+ // For ImageVariable, the upload field is varname[new]
+ if ($var instanceof ImageVariable) {
+ $name .= '[new]';
+ }
+
+ if (!str_contains($name, '[')) {
+ $file = $this->uploadedFiles[$name] ?? null;
+ return $file instanceof UploadedFileInterface ? $file : null;
+ }
+
+ // Navigate nested: "field[sub][key]" → ['field']['sub']['key']
+ $parts = explode('[', str_replace(']', '', $name));
+ $current = $this->uploadedFiles;
+ foreach ($parts as $part) {
+ if (!is_array($current) || !isset($current[$part])) {
+ return null;
+ }
+ $current = $current[$part];
+ }
+
+ return $current instanceof UploadedFileInterface ? $current : null;
+ }
+
/**
* Get a single variable value.
*
@@ -927,6 +1012,9 @@ public function validate($vars = null): bool
// Validate all variables in enabled groups
foreach ($this->getVariables(flat: true, withHidden: false, enabledOnly: true) as $var) {
+ if ($var instanceof FileUploadAware) {
+ $var->setUploadedFile($this->getUploadedFileFor($var));
+ }
if (!$var->validate($varsObject)) {
$this->errors[$var->getVarName()] = $var->getMessage();
}
@@ -934,6 +1022,9 @@ public function validate($vars = null): bool
// Validate hidden variables
foreach ($this->hiddenVariables as $var) {
+ if ($var instanceof FileUploadAware) {
+ $var->setUploadedFile($this->getUploadedFileFor($var));
+ }
if (!$var->validate($varsObject)) {
$this->errors[$var->getVarName()] = $var->getMessage();
}
@@ -1092,6 +1183,10 @@ private function getInfoFromVariables(array $variables, array $vars): array
continue;
}
+ if ($var instanceof FileUploadAware) {
+ $var->setUploadedFile($this->getUploadedFileFor($var));
+ }
+
$varName = $var->getVarName();
// Handle array values (field names ending with [])
diff --git a/src/V3/FileUploadAware.php b/src/V3/FileUploadAware.php
new file mode 100644
index 0000000..cd16dbc
--- /dev/null
+++ b/src/V3/FileUploadAware.php
@@ -0,0 +1,51 @@
+uploadedFile = $file;
+ * }
+ *
+ * protected function isValid($vars, $value): bool
+ * {
+ * if ($this->uploadedFile !== null) {
+ * // Use $this->uploadedFile->getError(), getSize(), etc.
+ * }
+ * // ...
+ * }
+ * }
+ * ```
+ *
+ * BaseForm resolves the uploaded file by matching the variable's name
+ * against the ServerRequest's uploaded files tree.
+ */
+interface FileUploadAware
+{
+ public function setUploadedFile(?UploadedFileInterface $file): void;
+}
diff --git a/src/V3/FileVariable.php b/src/V3/FileVariable.php
index 1c1358a..ffac605 100644
--- a/src/V3/FileVariable.php
+++ b/src/V3/FileVariable.php
@@ -1,39 +1,97 @@
createServerRequest('POST', '/upload');
+ * $request = $request->withUploadedFiles(['document' => $uploadedFile]);
+ * $request = $request->withParsedBody(['title' => 'Report']);
+ *
+ * $form = new BaseForm($request, 'Upload');
+ * $form->addVariable('Doc', 'document', 'file', true);
+ *
+ * if ($form->validate()) {
+ * $info = $form->getInfo();
+ * // $info['document']['name'] — client filename
+ * // $info['document']['type'] — client media type
+ * // $info['document']['size'] — file size in bytes
+ * // $info['document']['tmp_name'] — temp file path (stream written to disk)
+ * // $info['document']['file'] — same as tmp_name
+ * // $info['document']['error'] — UPLOAD_ERR_OK
+ * // $info['document']['uploaded_file'] — original UploadedFileInterface
+ * }
+ * ```
+ *
+ * The file content is written to a temp file so that consumers can use
+ * rename() to move it. Note: move_uploaded_file() will NOT work on this
+ * temp file because PHP did not create it via its upload mechanism.
+ * Use rename() or the UploadedFileInterface::moveTo() method instead.
+ *
+ * ## Legacy path (backward compatibility)
+ *
+ * When no UploadedFileInterface is injected (e.g., form data passed as
+ * array or Horde_Variables), the variable falls back to reading $_FILES
+ * and using $GLOBALS['browser']->wasFileUploaded() for validation.
+ * This path is unchanged from previous behavior.
*
* @see Horde_Form_Type_file PSR-0 legacy equivalent in lib/Horde/Form/Type.php
*/
-class FileVariable extends BaseVariable
+class FileVariable extends BaseVariable implements FileUploadAware
{
+ private ?UploadedFileInterface $uploadedFile = null;
+
+ public function setUploadedFile(?UploadedFileInterface $file): void
+ {
+ $this->uploadedFile = $file;
+ }
+
/**
* Validates file upload field.
*
- * Checks if a file was successfully uploaded using the browser's upload
- * detection. Required fields must have a file uploaded. Optional fields
- * pass validation even without a file.
+ * @param Horde_Variables|Variables $vars Form variables
+ * @param mixed $value Field value
+ * @return bool
*
- * @param Horde_Variables $vars Form variables
- * @param mixed $value Field value (not used; checks $_FILES directly)
- *
- * @return bool True if valid, false with error message set if required file missing
- *
- * @api
+ * @api
*/
public function isValid(Horde_Variables|Variables $vars, $value): bool
{
+ if ($this->uploadedFile !== null) {
+ $error = $this->uploadedFile->getError();
+ if ($error === UPLOAD_ERR_NO_FILE) {
+ if ($this->isRequired()) {
+ return $this->invalid(Horde_Form_Translation::t("This field is required."));
+ }
+ return true;
+ }
+ if ($error !== UPLOAD_ERR_OK) {
+ return $this->invalid(self::uploadErrorMessage($error));
+ }
+ return true;
+ }
+
+ // Legacy fallback: $_FILES via Horde_Browser
if ($this->isRequired()) {
try {
$GLOBALS['browser']->wasFileUploaded($this->getVarName());
@@ -46,18 +104,40 @@ public function isValid(Horde_Variables|Variables $vars, $value): bool
return true;
}
- //TODO: Rename back to getInfo() after the V3 transition
+ /**
+ * @api
+ */
protected function getInfoV3($vars)
{
+ if ($this->uploadedFile !== null) {
+ if ($this->uploadedFile->getError() !== UPLOAD_ERR_OK) {
+ return null;
+ }
+ $tmpFile = Horde::getTempFile('form_upload', false);
+ $stream = $this->uploadedFile->getStream();
+ $stream->rewind();
+ $dest = fopen($tmpFile, 'wb');
+ while (!$stream->eof()) {
+ fwrite($dest, $stream->read(8192));
+ }
+ fclose($dest);
+ return [
+ 'name' => $this->uploadedFile->getClientFilename(),
+ 'type' => $this->uploadedFile->getClientMediaType(),
+ 'tmp_name' => $tmpFile,
+ 'file' => $tmpFile,
+ 'size' => $this->uploadedFile->getSize(),
+ 'error' => UPLOAD_ERR_OK,
+ 'uploaded_file' => $this->uploadedFile,
+ ];
+ }
+
+ // Legacy fallback: $_FILES via Horde_Browser
$name = $this->getVarName();
try {
$GLOBALS['browser']->wasFileUploaded($name);
return [
- /**
- * WARNING: Horde_Util::dispelMagicQuotes() removed in PSR-4 version
- * Magic quotes are obsolete in PHP 8+. Remove this call.
- */
- 'name' => Horde_Util::dispelMagicQuotes($_FILES[$name]['name']),
+ 'name' => $_FILES[$name]['name'],
'type' => $_FILES[$name]['type'],
'tmp_name' => $_FILES[$name]['tmp_name'],
'file' => $_FILES[$name]['tmp_name'],
@@ -72,11 +152,24 @@ protected function getInfoV3($vars)
/**
* Return info about field type.
- *
- * @api
+ *
+ * @api
*/
public function about(): array
{
- return [ 'name' => Horde_Form_Translation::t("File upload") ];
+ return ['name' => Horde_Form_Translation::t("File upload")];
+ }
+
+ public static function uploadErrorMessage(int $code): string
+ {
+ return match ($code) {
+ UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => Horde_Form_Translation::t("The file was larger than the maximum allowed size."),
+ UPLOAD_ERR_PARTIAL => Horde_Form_Translation::t("The file was only partially uploaded."),
+ UPLOAD_ERR_NO_FILE => Horde_Form_Translation::t("No file was uploaded."),
+ UPLOAD_ERR_NO_TMP_DIR => Horde_Form_Translation::t("Server temporary directory missing."),
+ UPLOAD_ERR_CANT_WRITE => Horde_Form_Translation::t("Failed to write file to disk."),
+ UPLOAD_ERR_EXTENSION => Horde_Form_Translation::t("A PHP extension stopped the upload."),
+ default => Horde_Form_Translation::t("Unknown upload error."),
+ };
}
}
diff --git a/src/V3/ImageVariable.php b/src/V3/ImageVariable.php
index 95176a5..dc0ac6f 100644
--- a/src/V3/ImageVariable.php
+++ b/src/V3/ImageVariable.php
@@ -1,5 +1,7 @@
wasFileUploaded().
+ *
+ * ## Session-based image persistence
+ *
+ * Both paths use $GLOBALS['session'] to persist image data between
+ * requests (for preview/modify workflows). The session dependency is
+ * orthogonal to the upload source — it handles previously uploaded
+ * images, not the current upload.
+ *
* @property bool $show_upload Show the upload button
* @property bool $show_keeporig Show the option to upload also original non-modified image
* @property int|null $max_filesize Limit the file size
-
- *
- * PSR-4 implementation.
*
* @see Horde_Form_Type_image PSR-0 legacy equivalent in lib/Horde/Form/Type.php
*/
-class ImageVariable extends BaseVariable
+class ImageVariable extends BaseVariable implements FileUploadAware
{
+ private ?UploadedFileInterface $uploadedFile = null;
+
+ public function setUploadedFile(?UploadedFileInterface $file): void
+ {
+ $this->uploadedFile = $file;
+ }
/**
* Has a file been uploaded on this form submit?
*
@@ -248,13 +277,27 @@ private function _getUpload($vars)
}
/* Check if file has been uploaded. */
- try {
- $new = $varname . '[new]';
-
- $GLOBALS['browser']->wasFileUploaded($new);
- $this->_uploaded = true;
- } catch (Horde_Browser_Exception $e) {
- $this->_uploaded = $e;
+ if ($this->uploadedFile !== null) {
+ // PSR-7 path: use injected UploadedFileInterface
+ $error = $this->uploadedFile->getError();
+ if ($error === UPLOAD_ERR_OK) {
+ $this->_uploaded = true;
+ } else {
+ $this->_uploaded = new Horde_Browser_Exception(
+ FileVariable::uploadErrorMessage($error),
+ $error
+ );
+ }
+ } else {
+ // Legacy path: $_FILES via Horde_Browser
+ try {
+ $new = $varname . '[new]';
+
+ $GLOBALS['browser']->wasFileUploaded($new);
+ $this->_uploaded = true;
+ } catch (Horde_Browser_Exception $e) {
+ $this->_uploaded = $e;
+ }
}
if ($this->_uploaded === true) {
@@ -274,36 +317,49 @@ private function _getUpload($vars)
$tmp_file = Horde::getTempFile('Horde', false);
}
- /* Get the other parts of the upload. */
- $keys = ArrayUtils::getFieldParts($new);
-
- /* Get the temporary file name. */
- $file = ArrayUtils::getElement($_FILES, $keys, 'tmp_name');
-
- /* Move the browser created temp file to the new temp file. */
- move_uploaded_file($file, $tmp_file);
+ if ($this->uploadedFile !== null) {
+ // PSR-7 path: write stream to temp file
+ $stream = $this->uploadedFile->getStream();
+ $dest = fopen($tmp_file, 'wb');
+ while (!$stream->eof()) {
+ fwrite($dest, $stream->read(8192));
+ }
+ fclose($dest);
- /* Get the name value. */
- $name = ArrayUtils::getElement($_FILES, $keys, 'name');
+ $name = $this->uploadedFile->getClientFilename();
+ $type = $this->uploadedFile->getClientMediaType();
+ $size = $this->uploadedFile->getSize();
- /* Get the file type. */
- $type = ArrayUtils::getElement($_FILES, $keys, 'type');
- if ($type === null || $type === '' || $type === 'application/octet-stream') {
- /* Type wasn't set on upload, try analysing the upload. */
- $type = Horde_Mime_Magic::analyzeFile($tmp_file, $GLOBALS['conf']['mime']['magic_db'] ?? null);
- if ($type === false) {
- /* Work out the type from the file name. */
- $type = Horde_Mime_Magic::filenameToMime($name);
+ if ($type === null || $type === '' || $type === 'application/octet-stream') {
+ $type = Horde_Mime_Magic::analyzeFile($tmp_file, $GLOBALS['conf']['mime']['magic_db'] ?? null);
+ if ($type === false) {
+ $type = Horde_Mime_Magic::filenameToMime($name ?? '');
+ }
}
-
- /* Set the type. */
- ArrayUtils::setElement($_FILES, $keys, $type, 'type');
+ } else {
+ // Legacy path: read from $_FILES
+ $new = $varname . '[new]';
+ $keys = ArrayUtils::getFieldParts($new);
+
+ $file = ArrayUtils::getElement($_FILES, $keys, 'tmp_name');
+ move_uploaded_file($file, $tmp_file);
+
+ $name = ArrayUtils::getElement($_FILES, $keys, 'name');
+ $type = ArrayUtils::getElement($_FILES, $keys, 'type');
+ if ($type === null || $type === '' || $type === 'application/octet-stream') {
+ $type = Horde_Mime_Magic::analyzeFile($tmp_file, $GLOBALS['conf']['mime']['magic_db'] ?? null);
+ if ($type === false) {
+ $type = Horde_Mime_Magic::filenameToMime($name);
+ }
+ ArrayUtils::setElement($_FILES, $keys, $type, 'type');
+ }
+ $size = ArrayUtils::getElement($_FILES, $keys, 'size');
}
$img = [
'type' => $type,
'name' => $name,
- 'size' => ArrayUtils::getElement($_FILES, $keys, 'size'),
+ 'size' => $size,
'file' => basename($tmp_file),
];
} else {
diff --git a/test/unit/Type/FileUploadPsr7Test.php b/test/unit/Type/FileUploadPsr7Test.php
new file mode 100644
index 0000000..153d5a5
--- /dev/null
+++ b/test/unit/Type/FileUploadPsr7Test.php
@@ -0,0 +1,223 @@
+createStream($content);
+ return new UploadedFile(
+ $stream,
+ $streamFactory,
+ $filename,
+ $mediaType,
+ $error,
+ strlen($content)
+ );
+ }
+
+ private function createNoFileUpload(): UploadedFileInterface
+ {
+ $streamFactory = new StreamFactory();
+ $stream = $streamFactory->createStream('');
+ return new UploadedFile(
+ $stream,
+ $streamFactory,
+ '',
+ '',
+ UPLOAD_ERR_NO_FILE,
+ 0
+ );
+ }
+
+ // ========================================================================
+ // FileVariable with injected UploadedFileInterface
+ // ========================================================================
+
+ public function testIsValidReturnsTrueForSuccessfulUpload(): void
+ {
+ $upload = $this->createUploadedFile('PDF content', 'report.pdf', 'application/pdf');
+
+ $var = new FileVariable('Attachment', 'attachment', true);
+ $var->setUploadedFile($upload);
+
+ $vars = new \Horde_Variables([]);
+ $this->assertTrue($var->isValid($vars, null));
+ }
+
+ public function testIsValidReturnsFalseWhenRequiredAndNoFile(): void
+ {
+ $upload = $this->createNoFileUpload();
+
+ $var = new FileVariable('Attachment', 'attachment', true);
+ $var->setUploadedFile($upload);
+
+ $vars = new \Horde_Variables([]);
+ $this->assertFalse($var->isValid($vars, null));
+ }
+
+ public function testIsValidReturnsTrueWhenOptionalAndNoFile(): void
+ {
+ $upload = $this->createNoFileUpload();
+
+ $var = new FileVariable('Attachment', 'attachment', false);
+ $var->setUploadedFile($upload);
+
+ $vars = new \Horde_Variables([]);
+ $this->assertTrue($var->isValid($vars, null));
+ }
+
+ public function testGetInfoReturnsFileMetadataFromPsr7Upload(): void
+ {
+ $content = 'fake PDF bytes here';
+ $upload = $this->createUploadedFile($content, 'doc.pdf', 'application/pdf');
+
+ $var = new FileVariable('Document', 'document', false);
+ $var->setUploadedFile($upload);
+
+ $vars = new \Horde_Variables([]);
+ $info = $var->getInfo($vars);
+
+ $this->assertIsArray($info);
+ $this->assertSame('doc.pdf', $info['name']);
+ $this->assertSame('application/pdf', $info['type']);
+ $this->assertSame(strlen($content), $info['size']);
+ $this->assertSame(UPLOAD_ERR_OK, $info['error']);
+ $this->assertInstanceOf(UploadedFileInterface::class, $info['uploaded_file']);
+ // tmp_name should be a real file with the content
+ $this->assertFileExists($info['tmp_name']);
+ $this->assertSame($content, file_get_contents($info['tmp_name']));
+ }
+
+ public function testGetInfoReturnsNullWhenNoFileUploaded(): void
+ {
+ $upload = $this->createNoFileUpload();
+
+ $var = new FileVariable('Document', 'document', false);
+ $var->setUploadedFile($upload);
+
+ $vars = new \Horde_Variables([]);
+ $info = $var->getInfo($vars);
+
+ $this->assertNull($info);
+ }
+
+ public function testUploadErrorMessageIsTranslated(): void
+ {
+ $streamFactory = new StreamFactory();
+ $stream = $streamFactory->createStream('');
+ $upload = new UploadedFile($stream, $streamFactory, 'big.zip', 'application/zip', UPLOAD_ERR_INI_SIZE, 0);
+
+ $var = new FileVariable('File', 'file', true);
+ $var->setUploadedFile($upload);
+
+ $vars = new \Horde_Variables([]);
+ $this->assertFalse($var->isValid($vars, null));
+ $this->assertNotEmpty($var->getMessage());
+ }
+
+ // ========================================================================
+ // FileUploadAware interface check
+ // ========================================================================
+
+ public function testFileVariableImplementsFileUploadAware(): void
+ {
+ $var = new FileVariable('File', 'file', false);
+ $this->assertInstanceOf(FileUploadAware::class, $var);
+ }
+
+ // ========================================================================
+ // BaseForm integration with PSR-7 request
+ // ========================================================================
+
+ public function testBaseFormExtractsUploadedFilesFromPsr7Request(): void
+ {
+ $content = 'image data';
+ $upload = $this->createUploadedFile($content, 'photo.jpg', 'image/jpeg');
+
+ $request = new ServerRequest('POST', '/submit');
+ $request = $request->withParsedBody(['title' => 'My Photo']);
+ $request = $request->withUploadedFiles(['photo' => $upload]);
+
+ $form = new BaseForm($request, 'Upload Form');
+ $form->addVariable('Title', 'title', 'text', true);
+ $form->addVariable('Photo', 'photo', 'file', true);
+
+ $this->assertTrue($form->validate());
+
+ $info = $form->getInfo();
+ $this->assertSame('My Photo', $info['title']);
+ $this->assertIsArray($info['photo']);
+ $this->assertSame('photo.jpg', $info['photo']['name']);
+ $this->assertSame('image/jpeg', $info['photo']['type']);
+ $this->assertFileExists($info['photo']['tmp_name']);
+ $this->assertSame($content, file_get_contents($info['photo']['tmp_name']));
+ }
+
+ public function testBaseFormValidationFailsForMissingRequiredFile(): void
+ {
+ $upload = $this->createNoFileUpload();
+
+ $request = new ServerRequest('POST', '/submit');
+ $request = $request->withParsedBody(['title' => 'No Photo']);
+ $request = $request->withUploadedFiles(['photo' => $upload]);
+
+ $form = new BaseForm($request, 'Upload Form');
+ $form->useToken(false);
+ $form->addVariable('Title', 'title', 'text', true);
+ $form->addVariable('Photo', 'photo', 'file', true);
+
+ $this->assertFalse($form->validate());
+ $this->assertNotNull($form->getError('photo'));
+ }
+
+ public function testBaseFormLegacyPathUnaffectedWhenArrayInput(): void
+ {
+ // When input is a plain array, no uploaded files are extracted —
+ // the legacy $_FILES path remains active (tested in FileTypeTest)
+ $form = new BaseForm(['name' => 'test'], 'Simple Form');
+ $form->useToken(false);
+ $form->addVariable('Name', 'name', 'text', true);
+
+ $this->assertTrue($form->validate());
+ $info = $form->getInfo();
+ $this->assertSame('test', $info['name']);
+ }
+}