Skip to content

Commit 6500daa

Browse files
committed
Merge branch 'feature/url-rewriting' into develop
2 parents 03d8388 + fdd3815 commit 6500daa

6 files changed

Lines changed: 896 additions & 23 deletions

File tree

README.md

Lines changed: 197 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -174,25 +174,27 @@ public function getUsers()
174174

175175
#### Enable Attribute Routing in MVC Application
176176

177-
Add controller paths to your `config/neuron.yaml`:
177+
Add controller paths to your `config/routing.yaml`:
178+
179+
```yaml
180+
# config/routing.yaml
181+
controller_paths:
182+
- path: 'src/Controllers'
183+
namespace: 'App\Controllers'
184+
- path: 'src/Admin/Controllers'
185+
namespace: 'App\Admin\Controllers'
186+
```
187+
188+
For backward compatibility, controller paths can also be configured in `config/neuron.yaml`:
178189

179190
```yaml
180191
routing:
181192
controller_paths:
182193
- path: 'src/Controllers'
183194
namespace: 'App\Controllers'
184-
- path: 'src/Admin/Controllers'
185-
namespace: 'App\Admin\Controllers'
186195
```
187196

188-
#### Hybrid Approach (YAML + Attributes)
189-
190-
You can use both YAML routes and attribute routes together:
191-
192-
- **YAML routes**: Legacy routes, package-provided routes
193-
- **Attribute routes**: New application routes
194-
195-
The MVC Application will load both automatically.
197+
**Note:** If both files exist, `routing.yaml` takes precedence.
196198

197199
### Benefits
198200

@@ -234,6 +236,190 @@ class Home
234236

235237
See `tests/unit/RouteScannerTest.php` for working examples of basic route definition, route groups with prefixes, filter composition, and multiple routes per method.
236238

239+
## URL Rewrites
240+
241+
URL rewrites provide transparent URL rewriting before route matching. Unlike HTTP redirects (301/302), rewrites are internal and invisible to the client—the browser URL stays the same while the application routes to a different path.
242+
243+
### Use Cases
244+
245+
- **Override Package Routes**: Applications can override default routes from packages (e.g., CMS homepage)
246+
- **Legacy URL Support**: Support old URLs without creating duplicate routes
247+
- **Clean URLs**: Map user-friendly URLs to internal route structures
248+
- **Environment-Specific Routing**: Different rewrites for dev/staging/production
249+
250+
### Configuration
251+
252+
Add rewrites to `config/routing.yaml`:
253+
254+
```yaml
255+
# config/routing.yaml
256+
rewrites:
257+
'/': '/home' # Root goes to custom homepage
258+
'/index': '/home' # Legacy URL support
259+
'/index.php': '/home' # Handle old PHP URLs
260+
'/blog': '/posts' # URL aliasing
261+
'/about-us': '/company/about' # Clean URL to internal structure
262+
```
263+
264+
### How It Works
265+
266+
```text
267+
1. Client requests: http://example.com/
268+
2. Router receives: /
269+
3. Rewrite applied: / → /home
270+
4. Route matching: Finds route for /home
271+
5. Response sent to client
272+
6. Browser still shows: http://example.com/
273+
```
274+
275+
### Example: CMS Homepage Override
276+
277+
**Problem:** CMS defines `GET /` but you want a custom homepage.
278+
279+
**Solution:**
280+
```yaml
281+
# config/routing.yaml
282+
rewrites:
283+
'/': '/custom/landing' # Rewrite root to your controller
284+
285+
controller_paths:
286+
- path: 'app/Controllers' # Your controllers first
287+
namespace: 'App\Controllers'
288+
- path: 'vendor/neuron-php/cms/src/Cms/Controllers'
289+
namespace: 'Neuron\Cms\Controllers'
290+
```
291+
292+
```php
293+
// app/Controllers/Landing.php
294+
class Landing extends Controller
295+
{
296+
#[Get('/custom/landing', name: 'landing')]
297+
public function index()
298+
{
299+
return $this->renderHtml(OK, [], 'custom-home');
300+
}
301+
}
302+
```
303+
304+
Now requests to `/` are transparently routed to your custom landing page without any HTTP redirect.
305+
306+
### Rewrite Rules
307+
308+
- **Exact Match Only**: Rewrites use exact string matching, no wildcards or regex
309+
- **Applied Before Route Matching**: Rewrites happen before the router looks for routes
310+
- **Original URL Preserved**: Client never sees the rewritten URL
311+
- **Logging**: Rewrites are logged at debug level for troubleshooting
312+
313+
### vs. HTTP Redirects
314+
315+
| Feature | URL Rewrite | HTTP Redirect |
316+
|---------|-------------|---------------|
317+
| **Client Visible** | No | Yes |
318+
| **HTTP Request Count** | 1 | 2 |
319+
| **Performance** | Fast | Slower |
320+
| **SEO Impact** | None | Can affect SEO |
321+
| **Use Case** | Internal routing | Moved content |
322+
323+
## Duplicate Route Detection
324+
325+
The router includes strict duplicate route detection to catch configuration errors early and prevent hard-to-debug routing issues.
326+
327+
### What Gets Detected
328+
329+
#### 1. Duplicate Method + Path
330+
331+
```php
332+
// ❌ ERROR: Duplicate route
333+
#[Get('/users')]
334+
public function index() { }
335+
336+
#[Get('/users')] // Same method + path
337+
public function list() { }
338+
```
339+
340+
**Error Message:**
341+
```text
342+
Duplicate route detected: GET /users
343+
First: App\Controllers\UserController@index
344+
Second: App\Controllers\UserController@list
345+
Suggestion: Use different paths, different HTTP methods, or combine into one controller method.
346+
```
347+
348+
#### 2. Duplicate Route Names
349+
350+
```php
351+
// ❌ ERROR: Duplicate name
352+
#[Get('/users', name: 'users')]
353+
public function index() { }
354+
355+
#[Post('/users/create', name: 'users')] // Same name
356+
public function store() { }
357+
```
358+
359+
**Error Message:**
360+
```text
361+
Duplicate route name detected: 'users'
362+
First: GET /users → App\Controllers\UserController@index
363+
Second: POST /users/create → App\Controllers\UserController@store
364+
Suggestion: Use different route names or remove one of the routes.
365+
```
366+
367+
### What's Allowed
368+
369+
#### Different HTTP Methods (RESTful)
370+
371+
```php
372+
// ✅ ALLOWED: Same path, different methods
373+
#[Get('/users', name: 'users.index')]
374+
public function index() { }
375+
376+
#[Post('/users', name: 'users.store')]
377+
public function store() { }
378+
```
379+
380+
This is standard RESTful routing and is fully supported.
381+
382+
#### Multiple Attributes on Same Method (Aliases)
383+
384+
```php
385+
// ✅ ALLOWED: Multiple routes to same handler
386+
#[Get('/user/:id')]
387+
#[Get('/profile/:id')]
388+
#[Get('/member/:id')]
389+
public function show($id)
390+
{
391+
// Backward compatibility or URL aliasing
392+
}
393+
```
394+
395+
### Configuration
396+
397+
Strict mode is **enabled by default**. To disable (not recommended):
398+
399+
```php
400+
$router = Router::instance();
401+
$router->setStrictMode(false); // Allow duplicates (first match wins)
402+
```
403+
404+
### Benefits
405+
406+
- **Catches Errors Early**: Fails at application boot, not during user requests
407+
- **Clear Error Messages**: Shows both conflicting routes with file locations
408+
- **Prevents Production Bugs**: No silent overwrites or unexpected behavior
409+
- **Developer-Friendly**: Suggests solutions in error messages
410+
411+
### Why This Matters
412+
413+
**Without duplicate detection:**
414+
- Same route defined twice? Second silently ignored, first wins
415+
- Same name used twice? URL generation picks random route
416+
- Debugging nightmare when routes mysteriously don't work
417+
418+
**With duplicate detection:**
419+
- Application fails to start with clear error
420+
- Developer fixes the conflict immediately
421+
- Production deployments are safer
422+
237423
## Rate Limiting
238424

239425
The routing component includes a powerful rate limiting system with multiple storage backends and flexible configuration options.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
3+
namespace Neuron\Routing\Exceptions;
4+
5+
use Exception;
6+
7+
/**
8+
* Exception thrown when duplicate routes are detected during registration.
9+
*
10+
* This exception helps developers identify routing conflicts early by providing
11+
* detailed information about both the original and duplicate route definitions.
12+
*
13+
* @package Neuron\Routing\Exceptions
14+
*/
15+
class DuplicateRouteException extends Exception
16+
{
17+
private string $firstMethod;
18+
private string $firstPath;
19+
private string $firstDefinition;
20+
private string $secondMethod;
21+
private string $secondPath;
22+
private string $secondDefinition;
23+
private ?string $routeName;
24+
25+
/**
26+
* Create a new duplicate route exception.
27+
*
28+
* @param string $firstMethod First route's HTTP method (GET, POST, etc.)
29+
* @param string $firstPath First route's path (e.g., /users/:id)
30+
* @param string $first First route definition (controller@method)
31+
* @param string $secondMethod Second route's HTTP method
32+
* @param string $secondPath Second route's path
33+
* @param string $second Second (duplicate) route definition
34+
* @param string|null $name Optional route name if the conflict is name-based
35+
*/
36+
public function __construct(
37+
string $firstMethod,
38+
string $firstPath,
39+
string $first,
40+
string $secondMethod,
41+
string $secondPath,
42+
string $second,
43+
?string $name = null
44+
)
45+
{
46+
$this->firstMethod = $firstMethod;
47+
$this->firstPath = $firstPath;
48+
$this->firstDefinition = $first;
49+
$this->secondMethod = $secondMethod;
50+
$this->secondPath = $secondPath;
51+
$this->secondDefinition = $second;
52+
$this->routeName = $name;
53+
54+
$message = $this->buildMessage();
55+
parent::__construct( $message );
56+
}
57+
58+
/**
59+
* Build a detailed error message for the duplicate route.
60+
*
61+
* @return string
62+
*/
63+
protected function buildMessage(): string
64+
{
65+
if( $this->routeName )
66+
{
67+
return sprintf(
68+
"Duplicate route name detected: '%s'\n" .
69+
" First: %s %s → %s\n" .
70+
" Second: %s %s → %s\n" .
71+
"Suggestion: Use different route names or remove one of the routes.",
72+
$this->routeName,
73+
$this->firstMethod,
74+
$this->firstPath,
75+
$this->firstDefinition,
76+
$this->secondMethod,
77+
$this->secondPath,
78+
$this->secondDefinition
79+
);
80+
}
81+
82+
return sprintf(
83+
"Duplicate route detected: %s %s\n" .
84+
" First: %s\n" .
85+
" Second: %s\n" .
86+
"Suggestion: Use different paths, different HTTP methods, or combine into one controller method.",
87+
$this->secondMethod,
88+
$this->secondPath,
89+
$this->firstDefinition,
90+
$this->secondDefinition
91+
);
92+
}
93+
94+
/**
95+
* Get the HTTP method of the first route.
96+
*
97+
* @return string
98+
*/
99+
public function getFirstMethod(): string
100+
{
101+
return $this->firstMethod;
102+
}
103+
104+
/**
105+
* Get the path of the first route.
106+
*
107+
* @return string
108+
*/
109+
public function getFirstPath(): string
110+
{
111+
return $this->firstPath;
112+
}
113+
114+
/**
115+
* Get the HTTP method of the second route.
116+
*
117+
* @return string
118+
*/
119+
public function getSecondMethod(): string
120+
{
121+
return $this->secondMethod;
122+
}
123+
124+
/**
125+
* Get the path of the second route.
126+
*
127+
* @return string
128+
*/
129+
public function getSecondPath(): string
130+
{
131+
return $this->secondPath;
132+
}
133+
134+
/**
135+
* Get the first route definition.
136+
*
137+
* @return string
138+
*/
139+
public function getFirstDefinition(): string
140+
{
141+
return $this->firstDefinition;
142+
}
143+
144+
/**
145+
* Get the second (duplicate) route definition.
146+
*
147+
* @return string
148+
*/
149+
public function getSecondDefinition(): string
150+
{
151+
return $this->secondDefinition;
152+
}
153+
154+
/**
155+
* Get the route name if this is a name-based conflict.
156+
*
157+
* @return string|null
158+
*/
159+
public function getRouteName(): ?string
160+
{
161+
return $this->routeName;
162+
}
163+
}

0 commit comments

Comments
 (0)