Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .horde.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ autoload:
classmap:
- compat/
vendor: horde
keywords:
- framework
- spl
- pear_error
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Horde Exception

Exception handling library for the [Horde Project](https://www.horde.org/).

Provides a set of exception base classes that integrate with the Horde
framework. All exceptions implement `HordeThrowable` (details support) and
`LogThrowable` (log-level tracking), and extend PHP's SPL exception hierarchy
for proper semantic categorisation.

## Installation

```bash
composer require horde/exception
```

## Quick Start

```php
use Horde\Exception\HordeRuntimeException;
use Horde\Exception\HordeThrowable;

// Throw a typed exception
throw new HordeRuntimeException('Connection failed', 0, $previous);

// Catch any Horde exception via the interface
try {
// ...
} catch (HordeThrowable $e) {
echo $e->getMessage();
echo $e->getDetails();
}
```

## Choosing an Exception Base Class

| Error type | Base class |
|---|---|
| Runtime / I/O failure | `HordeRuntimeException` |
| Invalid argument from caller | `HordeInvalidArgumentException` |
| Programmer logic error | `HordeLogicException` |
| Domain rule violation | `HordeDomainException` |
| Resource not found | `NotFound` |
| Permission denied | `PermissionDenied` |
| Generic / uncategorised | `HordeException` |

Component exceptions should extend the SPL-based class that best fits
(e.g. `class CacheException extends HordeRuntimeException {}`).

See [doc/USAGE.md](doc/USAGE.md) for the full use-case table, guidance on
`Wrapped` vs standard exceptions, and component exception patterns.

## Documentation

- **[doc/USAGE.md](doc/USAGE.md)** — Choosing the right base class, the
`Wrapped` pitfall, creating component exceptions
- **[doc/UPGRADING.md](doc/UPGRADING.md)** — Migrating from PSR-0 to PSR-4,
class mapping table, breaking changes

## License

LGPL-2.1-only. See [LICENSE](LICENSE) for details.
7 changes: 3 additions & 4 deletions compat/PEAR.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

/**
* PEAR compatibility class for legacy code.
*
Expand All @@ -9,7 +10,7 @@
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @author Ralf Lang <lang@b1-systems.de>
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @category Horde
* @license http://www.horde.org/licenses/lgpl21 LGPL-2.1
* @package Exception
Expand All @@ -21,6 +22,4 @@
*
* Extends Horde\Exception\PEAR which will be autoloaded by PSR-4.
*/
class PEAR extends Horde\Exception\PEAR
{
}
class PEAR extends Horde\Exception\PEAR {}
7 changes: 3 additions & 4 deletions compat/PEAR/Error.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

/**
* PEAR_Error compatibility class for legacy code.
*
Expand All @@ -9,7 +10,7 @@
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @author Ralf Lang <lang@b1-systems.de>
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @category Horde
* @license http://www.horde.org/licenses/lgpl21 LGPL-2.1
* @package Exception
Expand All @@ -21,6 +22,4 @@
*
* Extends Horde\Exception\PearError which will be autoloaded by PSR-4.
*/
class PEAR_Error extends Horde\Exception\PearError
{
}
class PEAR_Error extends Horde\Exception\PearError {}
33 changes: 27 additions & 6 deletions doc/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Legacy PSR-0 `lib/` remain for backward compatibility with the old interface tho

## Requirements

- PHP 8.2 or later
- PHP 8.1 or later

## Key Changes

Expand Down Expand Up @@ -143,15 +143,36 @@ throw $e;

### Wrapped Exception

> **Note:** `Wrapped` is intended for converting `PEAR_Error` objects, not for
> general exception chaining. See [USAGE.md](USAGE.md) for guidance on choosing
> the right base class and why `Wrapped` is usually the wrong choice for
> component exceptions.

```php
// Old
throw new Horde_Exception_Wrapped('Context', $previous);
// Converting PEAR_Error (the intended use)
throw new Horde_Exception_Wrapped($pearError);

// New
use Horde\Exception\Wrapped;
throw new Wrapped('Context', $previous);
// For exception chaining, use $previous (third arg) on any exception:
throw new HordeRuntimeException('Context', 0, $previousException);
```

### Migrating Component Exceptions Off Wrapped

Many component exceptions historically extend `Horde_Exception_Wrapped`.
The modern replacement is an SPL-based Horde exception:

```php
// Old — component exception on Wrapped (loses $previous in lib/ path)
class Horde_MyComponent_Exception extends Horde_Exception_Wrapped {}

// New — in src/, extend the matching SPL variant
use Horde\Exception\HordeRuntimeException;
class MyComponentException extends HordeRuntimeException {}
```

See [USAGE.md](USAGE.md) for the full use-case table and component exception
patterns.

### Not Found Exception

```php
Expand Down
157 changes: 157 additions & 0 deletions doc/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Horde Exception — Usage Guide

## Choosing the Right Exception Base Class

Every Horde exception implements `HordeThrowable` and `LogThrowable`, giving you
`getDetails()`/`setDetails()` and `markAsLogged()`/`isLogged()` regardless of
which base you pick. The question is **which SPL base** fits your error.

### Use-Case Table

| Use case | Traditional (lib/) | Modern (src/) |
|---|---|---|
| Generic error, no specific category | `Horde_Exception` | `HordeException` |
| Runtime failure (I/O, process, network) | `Horde_Exception` | `HordeRuntimeException` |
| Caller passed an invalid argument | `Horde_Exception` | `HordeInvalidArgumentException` |
| Programmer error / impossible state | `Horde_Exception` | `HordeLogicException` |
| Value outside domain rules | `Horde_Exception` | `HordeDomainException` |
| Invalid method call / wrong state | `Horde_Exception` | `HordeBadMethodCallException` |
| Length constraint violated | `Horde_Exception` | `HordeLengthException` |
| Index out of bounds (data-dependent) | `Horde_Exception` | `HordeOutOfBoundsException` |
| Index out of range (programmer error) | `Horde_Exception` | `HordeOutOfRangeException` |
| Unexpected value from external source | `Horde_Exception` | `HordeUnexpectedValueException` |
| Resource not found (404-style) | `Horde_Exception_NotFound` | `NotFound` |
| Permission denied (403-style) | `Horde_Exception_PermissionDenied` | `PermissionDenied` |
| Wrapping a PHP error from `error_get_last()` | `Horde_Exception_LastError` | `LastError` |
| Converting a PEAR_Error | `Horde_Exception_Pear` | `Pear` (deprecated) |
| Wrapping a PEAR_Error with userinfo extraction | `Horde_Exception_Wrapped` | `Wrapped` (see below) |

### The Rule of Thumb

- **Component exceptions** (e.g. `SpellCheckException`, `CacheException`)
should extend the SPL-based class that matches the error category — usually
`HordeRuntimeException` for I/O-related failures.

- **`HordeException`** is the catch-all. If none of the specific SPL classes
fit, use it — but prefer a specific one when one applies.

- Libraries should carefully choose: If they are fairly isolated from Horde ecosystem and have a high
chance of usage outside, NOT relying on the Horde exception tree might be attractive and reduce dependency creep.

- **Broad Catch against `HordeThrowable`**: If you need a broad catch, catch against HordeThrowable. This catches both
PSR-0 and PSR-4 exceptions. This neatly separates framework internal concerns (we potentially know to handle) from outside failures
(which we would have caught somewhere more specific, closer to the root cause if we knew to handle them).

## What `Wrapped` Is For (and Why You Probably Don't Want It)

### Purpose

`Wrapped` exists for one specific use case from the Horde 3/4 era: converting a
**`PEAR_Error` object** into an exception. When you pass a `PEAR_Error` as the
first argument, `Wrapped` extracts:

- The message via `getMessage()`
- The error code via `getCode()`
- Userinfo via `getUserinfo()` → stored in `$details`

```php
// The intended use — wrapping a PEAR_Error:
$pearError = PEAR::raiseError('something failed', 42);
throw new Horde_Exception_Wrapped($pearError);
// message = "something failed", code = 42, details = userinfo
```

### Why It's Usually the Wrong Choice

`Wrapped` was the only way to preserve exception chain context in the PEAR era.
Modern PHP has had `$previous` (the third constructor argument) since PHP 5.3.
Every SPL exception — and every Horde exception — already supports it:

```php
throw new HordeRuntimeException('Context message', 0, $previousException);
```

Using `Wrapped` for exception chaining instead of `$previous` creates problems:

1. **Legacy `Horde_Exception_Wrapped` silently drops `$previous`.**
Its constructor signature is `($message, $code)` — only two parameters.
If you write `new Horde_Exception_Wrapped($msg, $code, $previous)`, the
third argument is silently ignored by PHP. The chain is lost.

2. **Passing an Exception as `$message` is a workaround, not an API.**
`Wrapped` detects `instanceof Exception` on the first argument and sets
`$previous` internally. This works but is confusing — callers expect the
first argument to be a string.

3. **No SPL specificity.** `Wrapped` extends `HordeException` which extends
`Exception`. You lose the ability to distinguish runtime errors from logic
errors, invalid arguments from domain violations, etc.

### What to Use Instead

| Situation | Wrong | Right |
|---|---|---|
| Rethrowing with context | `new Wrapped($e)` | `new HordeRuntimeException('Context', 0, $e)` |
| Component exception chain | `new MyException($msg, $code, $e)` extending `Wrapped` | `new MyException($msg, $code, $e)` extending `HordeRuntimeException` |
| Converting PEAR_Error | — | `new Wrapped($pearError)` (this is the valid use) |

### Migration from `Wrapped`

If your component exception currently extends `Horde_Exception_Wrapped` or
`Horde\Exception\Wrapped`:

```php
// Before — extends Wrapped, loses $previous on legacy path
class Horde_MyComponent_Exception extends Horde_Exception_Wrapped {}

// After — component exception with proper chain support
// In src/:
use Horde\Exception\HordeRuntimeException;
class MyComponentException extends HordeRuntimeException {}

// In lib/ (wrapper, kept for BC):
class Horde_MyComponent_Exception extends Horde_Exception_Wrapped {}
// Document in phpdoc: wontfix/BC, new code should use src/ exception
```

## Creating Component Exceptions

A component exception should:

1. Live in `src/Exception/` under the component namespace
2. Extend the SPL-based Horde exception that best fits
3. Add no logic unless the component genuinely needs it

```php
<?php
// src/Exception/SpellCheckException.php

declare(strict_types=1);

namespace Horde\SpellChecker\Exception;

use Horde\Exception\HordeRuntimeException;

class SpellCheckException extends HordeRuntimeException {}
```

Callers catch it specifically or via interface:

```php
use Horde\SpellChecker\Exception\SpellCheckException;
use Horde\Exception\HordeThrowable;

// Specific
try {
$checker->spellCheck($text);
} catch (SpellCheckException $e) {
// handle spellcheck failure
}

// Broad — catches any Horde exception
try {
$checker->spellCheck($text);
} catch (HordeThrowable $e) {
// handle any Horde error
}
```
16 changes: 10 additions & 6 deletions lib/Horde/Exception.php
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
<?php

/**
* Copyright 2008-2017 Horde LLC (http://www.horde.org/)
* Copyright 2008-2026 Horde LLC (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @author
* @author Jan Schneider <jan@horde.org>
* @author Chuck Hagenbuch <chuck@horde.org>
* @author Michael Slusarz <slusarz@horde.org>
* @category Horde
* @license http://www.horde.org/licenses/lgpl21 LGPL-2.1
* @package Exception
*/
use Horde\Exception\HordeException;

/**
* Horde base exception class.
*
* @author
* @author Jan Schneider <jan@horde.org>
* @author Chuck Hagenbuch <chuck@horde.org>
* @author Michael Slusarz <slusarz@horde.org>
* @category Horde
* @copyright 2008-2017 Horde LLC
* @license http://www.horde.org/licenses/lgpl21 LGPL-2.1
* @package Exception
*/
class Horde_Exception extends HordeException
{
}
class Horde_Exception extends HordeException {}
11 changes: 8 additions & 3 deletions lib/Horde/Exception/LastError.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<?php

/**
* Copyright 2008-2017 Horde LLC (http://www.horde.org/)
* Copyright 2008-2026 Horde LLC (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @author
* @author Jan Schneider <jan@horde.org>
* @author Chuck Hagenbuch <chuck@horde.org>
* @author Michael Slusarz <slusarz@horde.org>
* @category Horde
* @license http://www.horde.org/licenses/lgpl21 LGPL
* @package Exception
Expand All @@ -15,7 +18,9 @@
* Horde exception class that accepts output of error_get_last() as $code and
* mask itself as that error.
*
* @author
* @author Jan Schneider <jan@horde.org>
* @author Chuck Hagenbuch <chuck@horde.org>
* @author Michael Slusarz <slusarz@horde.org>
* @category Horde
* @copyright 2008-2017 Horde LLC
* @license http://www.horde.org/licenses/lgpl21 LGPL
Expand Down
Loading
Loading