• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

markrogoyski / ipv4-subnet-calculator-php / 20985512917

14 Jan 2026 07:04AM UTC coverage: 97.956% (+0.9%) from 97.05%
20985512917

push

github

markrogoyski
Remove unnecessary code block.

623 of 636 relevant lines covered (97.96%)

83.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.85
/src/SubnetCalculatorFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace IPv4;
6

7
/**
8
 * Factory class for creating SubnetCalculator instances from various input formats.
9
 *
10
 * Provides flexible ways to instantiate SubnetCalculator objects, separating
11
 * construction concerns from the main class and improving developer experience.
12
 *
13
 * @link https://datatracker.ietf.org/doc/html/rfc3021 RFC 3021 - Using 31-Bit Prefixes on IPv4 Point-to-Point Links
14
 * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
15
 */
16
class SubnetCalculatorFactory
17
{
18
    /**
19
     * Create SubnetCalculator from CIDR notation string (e.g., "192.168.1.0/24")
20
     *
21
     * @param string $cidr CIDR notation (IP address with prefix, e.g., "192.168.1.0/24")
22
     *
23
     * @return SubnetCalculator
24
     *
25
     * @throws \InvalidArgumentException If CIDR format is invalid (missing slash, empty prefix)
26
     * @throws \UnexpectedValueException If IP address or network size is invalid
27
     */
28
    public static function fromCidr(string $cidr): SubnetCalculator
29
    {
30
        // Validate CIDR format
31
        if (\strpos($cidr, '/') === false) {
298✔
32
            throw new \InvalidArgumentException("Invalid CIDR notation: missing '/' prefix delimiter in '{$cidr}'");
1✔
33
        }
34

35
        $parts = \explode('/', $cidr);
297✔
36

37
        if (\count($parts) !== 2) {
297✔
38
            throw new \InvalidArgumentException("Invalid CIDR notation: multiple '/' found in '{$cidr}'");
1✔
39
        }
40

41
        [$ipAddress, $prefix] = $parts;
296✔
42

43
        if ($prefix === '') {
296✔
44
            throw new \InvalidArgumentException("Invalid CIDR notation: empty prefix in '{$cidr}'");
1✔
45
        }
46

47
        if (!\is_numeric($prefix)) {
295✔
48
            throw new \InvalidArgumentException("Invalid CIDR notation: non-numeric prefix '{$prefix}' in '{$cidr}'");
1✔
49
        }
50

51
        $networkSize = (int) $prefix;
294✔
52

53
        // Let SubnetCalculator validate IP address and network size
54
        return new SubnetCalculator($ipAddress, $networkSize);
294✔
55
    }
56

57
    /**
58
     * Create SubnetCalculator from IP address and subnet mask (e.g., "192.168.1.0", "255.255.255.0")
59
     *
60
     * @param string $ipAddress  IP address in dotted quad notation
61
     * @param string $subnetMask Subnet mask in dotted quad notation (e.g., "255.255.255.0")
62
     *
63
     * @return SubnetCalculator
64
     *
65
     * @throws \InvalidArgumentException If subnet mask is invalid or non-contiguous
66
     * @throws \UnexpectedValueException If IP address is invalid
67
     */
68
    public static function fromMask(string $ipAddress, string $subnetMask): SubnetCalculator
69
    {
70
        $networkSize = self::maskToNetworkSize($subnetMask);
22✔
71

72
        return new SubnetCalculator($ipAddress, $networkSize);
14✔
73
    }
74

75
    /**
76
     * Create SubnetCalculator from IP address range (e.g., "192.168.1.0", "192.168.1.255")
77
     *
78
     * Note: The range must represent a valid CIDR block. The start IP must be the
79
     * network address and the end IP must be the broadcast address of a valid subnet.
80
     *
81
     * @param string $startIp Start IP address (network address)
82
     * @param string $endIp   End IP address (broadcast address)
83
     *
84
     * @return SubnetCalculator
85
     *
86
     * @throws \InvalidArgumentException If range does not represent a valid CIDR block
87
     * @throws \UnexpectedValueException If IP addresses are invalid
88
     */
89
    public static function fromRange(string $startIp, string $endIp): SubnetCalculator
90
    {
91
        // Validate IP addresses
92
        $startLong = self::validateAndConvertIp($startIp);
19✔
93
        $endLong = self::validateAndConvertIp($endIp);
18✔
94

95
        // Start must be <= End
96
        if ($startLong > $endLong) {
17✔
97
            throw new \InvalidArgumentException("Start IP '{$startIp}' is greater than end IP '{$endIp}'");
1✔
98
        }
99

100
        // Calculate the number of addresses in the range
101
        $rangeSize = $endLong - $startLong + 1;
16✔
102

103
        // Check if range size is a power of 2
104
        if (($rangeSize & ($rangeSize - 1)) !== 0) {
16✔
105
            throw new \InvalidArgumentException(
4✔
106
                "Range from '{$startIp}' to '{$endIp}' does not represent a valid CIDR block (size {$rangeSize} is not a power of 2)"
4✔
107
            );
108
        }
109

110
        // Calculate network size from range size
111
        $networkSize = 32 - (int) \log($rangeSize, 2);
12✔
112

113
        // Validate that start IP is properly aligned for this network size
114
        // A properly aligned network address has all host bits set to 0
115
        $mask = self::calculateSubnetMaskInt($networkSize);
12✔
116
        if (($startLong & $mask) !== $startLong) {
12✔
117
            throw new \InvalidArgumentException(
1✔
118
                "Start IP '{$startIp}' is not a valid network address for a /{$networkSize} subnet"
1✔
119
            );
120
        }
121

122
        return new SubnetCalculator($startIp, $networkSize);
11✔
123
    }
124

125
    /**
126
     * Create SubnetCalculator from IP address and number of required hosts.
127
     *
128
     * Returns the smallest subnet that can accommodate the host count.
129
     *
130
     * @param string $ipAddress Base IP address
131
     * @param int    $hostCount Number of hosts required
132
     *
133
     * @return SubnetCalculator
134
     *
135
     * @throws \InvalidArgumentException If host count is invalid (zero, negative, or too large)
136
     * @throws \UnexpectedValueException If IP address is invalid
137
     */
138
    public static function fromHostCount(string $ipAddress, int $hostCount): SubnetCalculator
139
    {
140
        // Validate host count
141
        if ($hostCount <= 0) {
15✔
142
            throw new \InvalidArgumentException("Host count must be positive, got {$hostCount}");
2✔
143
        }
144

145
        // Maximum possible hosts in IPv4 (for /1 network)
146
        // 2^31 - 2 = 2147483646
147
        if ($hostCount > 2147483646) {
13✔
148
            throw new \InvalidArgumentException("Host count {$hostCount} exceeds maximum possible hosts in IPv4");
1✔
149
        }
150

151
        // Calculate optimal network size
152
        $networkSize = self::calculateOptimalNetworkSize($hostCount);
12✔
153

154
        return new SubnetCalculator($ipAddress, $networkSize);
12✔
155
    }
156

157
    /**
158
     * Calculate the optimal CIDR prefix for a given host count.
159
     *
160
     * Returns the smallest prefix (largest network) that can accommodate
161
     * the specified number of hosts.
162
     *
163
     * For standard networks (/1 to /30), usable hosts = 2^(32-prefix) - 2
164
     * For /31 (RFC 3021), usable hosts = 2
165
     * For /32, usable hosts = 1
166
     *
167
     * @param int $hostCount Number of hosts required
168
     *
169
     * @return int Optimal CIDR prefix (network size)
170
     *
171
     * @throws \InvalidArgumentException If host count is invalid (zero, negative, or too large)
172
     *
173
     * @link https://datatracker.ietf.org/doc/html/rfc3021 RFC 3021 - Using 31-Bit Prefixes on IPv4 Point-to-Point Links
174
     */
175
    public static function optimalPrefixForHosts(int $hostCount): int
176
    {
177
        // Validate host count
178
        if ($hostCount <= 0) {
29✔
179
            throw new \InvalidArgumentException("Host count must be positive, got {$hostCount}");
2✔
180
        }
181

182
        // Maximum possible hosts in IPv4 (for /1 network)
183
        // 2^31 - 2 = 2147483646
184
        if ($hostCount > 2147483646) {
27✔
185
            throw new \InvalidArgumentException("Host count {$hostCount} exceeds maximum possible hosts in IPv4");
2✔
186
        }
187

188
        return self::calculateOptimalNetworkSize($hostCount);
25✔
189
    }
190

191
    /**
192
     * Convert a subnet mask to a network size (CIDR prefix)
193
     *
194
     * @param string $subnetMask Subnet mask in dotted quad notation
195
     *
196
     * @return int Network size (CIDR prefix)
197
     *
198
     * @throws \InvalidArgumentException If mask is invalid or non-contiguous
199
     */
200
    private static function maskToNetworkSize(string $subnetMask): int
201
    {
202
        // Validate format
203
        $quads = \explode('.', $subnetMask);
22✔
204
        if (\count($quads) !== 4) {
22✔
205
            throw new \InvalidArgumentException("Invalid subnet mask format: '{$subnetMask}'");
2✔
206
        }
207

208
        // Validate each octet and convert to integer
209
        $maskInt = 0;
20✔
210
        foreach ($quads as $i => $quad) {
20✔
211
            if (!\is_numeric($quad)) {
20✔
212
                throw new \InvalidArgumentException("Invalid subnet mask: non-numeric octet in '{$subnetMask}'");
1✔
213
            }
214
            $octet = (int) $quad;
20✔
215
            if ($octet < 0 || $octet > 255) {
20✔
216
                throw new \InvalidArgumentException("Invalid subnet mask: octet out of range in '{$subnetMask}'");
1✔
217
            }
218
            $maskInt = ($maskInt << 8) | $octet;
19✔
219
        }
220

221
        // Check for zero mask (invalid - would be /0)
222
        if ($maskInt === 0) {
18✔
223
            throw new \InvalidArgumentException("Invalid subnet mask: zero mask not supported");
1✔
224
        }
225

226
        // Validate that mask is contiguous (all 1s followed by all 0s)
227
        // A valid mask in binary looks like: 11111111111111111111111100000000
228
        // If we invert it and add 1, we should get a power of 2
229
        $inverted = ~$maskInt & 0xFFFFFFFF;
17✔
230
        if (($inverted & ($inverted + 1)) !== 0) {
17✔
231
            throw new \InvalidArgumentException("Invalid subnet mask: non-contiguous mask '{$subnetMask}'");
3✔
232
        }
233

234
        // Count the number of 1 bits (network size)
235
        $networkSize = 0;
14✔
236
        $tempMask = $maskInt;
14✔
237
        while ($tempMask & 0x80000000) {
14✔
238
            $networkSize++;
14✔
239
            $tempMask <<= 1;
14✔
240
            $tempMask &= 0xFFFFFFFF; // Keep it as 32-bit
14✔
241
        }
242

243
        return $networkSize;
14✔
244
    }
245

246
    /**
247
     * Validate an IP address and convert to long integer
248
     *
249
     * @param string $ipAddress IP address to validate
250
     *
251
     * @return int IP address as integer
252
     *
253
     * @throws \UnexpectedValueException If IP address is invalid
254
     */
255
    private static function validateAndConvertIp(string $ipAddress): int
256
    {
257
        if (!\filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
19✔
258
            throw new \UnexpectedValueException("Invalid IP address: '{$ipAddress}'");
2✔
259
        }
260

261
        $long = \ip2long($ipAddress);
18✔
262
        if ($long === false) {
18✔
263
            // @codeCoverageIgnore
264
            throw new \UnexpectedValueException("Invalid IP address: '{$ipAddress}'");
×
265
        }
266

267
        // Handle negative values on 32-bit systems
268
        if ($long < 0) {
18✔
269
            // @codeCoverageIgnore
270
            $long = $long + 4294967296;
×
271
        }
272

273
        return $long;
18✔
274
    }
275

276
    /**
277
     * Calculate subnet mask as integer from network size
278
     *
279
     * @param int $networkSize Network size (1-32)
280
     *
281
     * @return int Subnet mask as integer
282
     */
283
    private static function calculateSubnetMaskInt(int $networkSize): int
284
    {
285
        if ($networkSize === 0) {
12✔
286
            return 0;
1✔
287
        }
288
        return (0xFFFFFFFF << (32 - $networkSize)) & 0xFFFFFFFF;
11✔
289
    }
290

291
    /**
292
     * Aggregate multiple subnets into the smallest possible supernet(s).
293
     *
294
     * Combines contiguous subnets into larger summary routes to reduce routing
295
     * table size. Returns an array of SubnetCalculator objects representing
296
     * the minimal set of CIDR blocks that cover all input subnets.
297
     *
298
     * If subnets are not fully contiguous, multiple supernets will be returned.
299
     * Overlapping and duplicate subnets are handled by removing redundant entries.
300
     *
301
     * @param SubnetCalculator[] $subnets Array of subnets to aggregate
302
     *
303
     * @return SubnetCalculator[] Array of aggregated supernets
304
     *
305
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
306
     */
307
    public static function aggregate(array $subnets): array
308
    {
309
        if (empty($subnets)) {
27✔
310
            return [];
1✔
311
        }
312

313
        // Normalize all subnets to their network addresses and collect as [start, end, prefix] tuples
314
        $ranges = [];
26✔
315
        foreach ($subnets as $subnet) {
26✔
316
            $start = self::ipToUnsigned($subnet->getNetworkPortionInteger());
26✔
317
            $end = $start + $subnet->getNumberIPAddresses() - 1;
26✔
318
            $ranges[] = [$start, $end, $subnet->getNetworkSize()];
26✔
319
        }
320

321
        // Sort by start address, then by end address (larger ranges first)
322
        \usort($ranges, function ($a, $b) {
323
            if ($a[0] !== $b[0]) {
25✔
324
                return $a[0] <=> $b[0];
23✔
325
            }
326
            return $b[1] <=> $a[1]; // Larger ranges first when same start
2✔
327
        });
26✔
328

329
        // Remove subnets contained within others
330
        $filtered = [];
26✔
331
        foreach ($ranges as $range) {
26✔
332
            $isContained = false;
26✔
333
            foreach ($filtered as $existing) {
26✔
334
                if ($range[0] >= $existing[0] && $range[1] <= $existing[1]) {
25✔
335
                    $isContained = true;
3✔
336
                    break;
3✔
337
                }
338
            }
339
            if (!$isContained) {
26✔
340
                // Also remove any existing ranges that this one contains
341
                $filtered = \array_filter($filtered, function ($existing) use ($range) {
342
                    return !($existing[0] >= $range[0] && $existing[1] <= $range[1]);
22✔
343
                });
26✔
344
                $filtered[] = $range;
26✔
345
            }
346
        }
347

348
        // Re-sort after filtering
349
        \usort($filtered, function ($a, $b) {
350
            return $a[0] <=> $b[0];
22✔
351
        });
26✔
352

353
        // Convert back to [start, prefix] format for aggregation
354
        $blocks = [];
26✔
355
        foreach ($filtered as $range) {
26✔
356
            $blocks[] = [$range[0], $range[2]];
26✔
357
        }
358

359
        // Iteratively merge adjacent subnets
360
        $merged = true;
26✔
361
        while ($merged) {
26✔
362
            $merged = false;
26✔
363
            $newBlocks = [];
26✔
364
            $used = [];
26✔
365

366
            for ($i = 0; $i < \count($blocks); $i++) {
26✔
367
                if (isset($used[$i])) {
26✔
368
                    continue;
19✔
369
                }
370

371
                $current = $blocks[$i];
26✔
372
                $foundMerge = false;
26✔
373

374
                // Try to find a partner to merge with
375
                for ($j = $i + 1; $j < \count($blocks); $j++) {
26✔
376
                    $other = $blocks[$j];
22✔
377

378
                    // Check if they can be merged (but not below /1 since /0 is not supported)
379
                    if ($current[1] === $other[1] && $current[1] > 1) {
22✔
380
                        $prefix = $current[1];
21✔
381
                        $blockSize = 1 << (32 - $prefix);
21✔
382

383
                        // Check if they are adjacent
384
                        $currentEnd = $current[0] + $blockSize - 1;
21✔
385
                        if ($currentEnd + 1 === $other[0]) {
21✔
386
                            // Check if the merged block would be aligned
387
                            $newPrefix = $prefix - 1;
20✔
388
                            $newBlockSize = 1 << (32 - $newPrefix);
20✔
389
                            if (($current[0] % $newBlockSize) === 0) {
20✔
390
                                // Merge them
391
                                $newBlocks[] = [$current[0], $newPrefix];
19✔
392
                                $used[$i] = true;
19✔
393
                                $used[$j] = true;
19✔
394
                                $merged = true;
19✔
395
                                $foundMerge = true;
19✔
396
                                break;
19✔
397
                            }
398
                        }
399
                    }
400
                }
401

402
                if (!$foundMerge) {
26✔
403
                    $newBlocks[] = $current;
26✔
404
                    $used[$i] = true;
26✔
405
                }
406
            }
407

408
            $blocks = $newBlocks;
26✔
409

410
            // Re-sort blocks by start address
411
            \usort($blocks, function ($a, $b) {
412
                return $a[0] <=> $b[0];
13✔
413
            });
26✔
414
        }
415

416
        // Convert back to SubnetCalculator objects
417
        $result = [];
26✔
418
        foreach ($blocks as $block) {
26✔
419
            $ip = self::unsignedToIp($block[0]);
26✔
420
            $result[] = new SubnetCalculator($ip, (int) $block[1]);
26✔
421
        }
422

423
        return $result;
26✔
424
    }
425

426
    /**
427
     * Find the smallest single supernet that contains all given subnets.
428
     *
429
     * Returns the minimal CIDR block that encompasses all input subnets.
430
     * Unlike aggregate(), this always returns a single subnet, but may include
431
     * addresses not in any of the input subnets (gaps are filled).
432
     *
433
     * @param SubnetCalculator[] $subnets Array of subnets to summarize
434
     *
435
     * @return SubnetCalculator The smallest supernet containing all inputs
436
     *
437
     * @throws \InvalidArgumentException If the subnet array is empty
438
     *
439
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - Classless Inter-domain Routing (CIDR)
440
     */
441
    public static function summarize(array $subnets): SubnetCalculator
442
    {
443
        if (empty($subnets)) {
12✔
444
            throw new \InvalidArgumentException('Cannot summarize empty subnet array');
1✔
445
        }
446

447
        if (\count($subnets) === 1) {
11✔
448
            $subnet = $subnets[0];
1✔
449
            return new SubnetCalculator($subnet->getNetworkPortion(), $subnet->getNetworkSize());
1✔
450
        }
451

452
        // Find the minimum start IP and maximum end IP
453
        $minStart = null;
10✔
454
        $maxEnd = null;
10✔
455

456
        foreach ($subnets as $subnet) {
10✔
457
            $start = self::ipToUnsigned($subnet->getNetworkPortionInteger());
10✔
458
            $end = $start + $subnet->getNumberIPAddresses() - 1;
10✔
459

460
            if ($minStart === null || $start < $minStart) {
10✔
461
                $minStart = $start;
10✔
462
            }
463
            if ($maxEnd === null || $end > $maxEnd) {
10✔
464
                $maxEnd = $end;
10✔
465
            }
466
        }
467

468
        // Find the smallest prefix that covers this range
469
        // Start from /32 and work backwards until we find one that covers the range
470
        for ($prefix = 32; $prefix >= 1; $prefix--) {
10✔
471
            $blockSize = 1 << (32 - $prefix);
10✔
472
            // Find the network address for this prefix that contains minStart
473
            $networkStart = ((int) ($minStart / $blockSize)) * $blockSize;
10✔
474
            $networkEnd = $networkStart + $blockSize - 1;
10✔
475

476
            if ($networkStart <= $minStart && $networkEnd >= $maxEnd) {
10✔
477
                $ip = self::unsignedToIp((int) $networkStart);
9✔
478
                return new SubnetCalculator($ip, $prefix);
9✔
479
            }
480
        }
481

482
        // If we get here, the subnets span the entire IP space and would require /0
483
        throw new \InvalidArgumentException(
1✔
484
            'Cannot summarize: result would require /0 which is not a valid network size'
1✔
485
        );
486
    }
487

488
    /**
489
     * Convert a signed IP integer to unsigned for comparison
490
     *
491
     * @param int $ip Signed IP integer
492
     *
493
     * @return int Unsigned IP integer
494
     */
495
    private static function ipToUnsigned(int $ip): int
496
    {
497
        if ($ip < 0) {
×
498
            return $ip + 4294967296;
×
499
        }
500
        return $ip;
×
501
    }
502

503
    /**
504
     * Convert an unsigned IP integer back to a dotted-quad string
505
     *
506
     * @param int $ip Unsigned IP integer
507
     *
508
     * @return string Dotted-quad IP address
509
     */
510
    private static function unsignedToIp(int $ip): string
511
    {
512
        // Convert to signed for long2ip if needed
513
        if ($ip > 2147483647) {
×
514
            $ip = $ip - 4294967296;
×
515
        }
516
        $result = \long2ip($ip);
×
517
        if (!$result) {
×
518
            throw new \RuntimeException("Failed to convert IP integer to string: {$ip}");
×
519
        }
520
        return $result;
×
521
    }
522

523
    /**
524
     * Calculate the optimal network size for a given host count
525
     *
526
     * For standard networks (/1 to /30), usable hosts = 2^(32-prefix) - 2
527
     * For /31 (RFC 3021), usable hosts = 2
528
     * For /32, usable hosts = 1
529
     *
530
     * @param int $hostCount Number of hosts required
531
     *
532
     * @return int Optimal network size (CIDR prefix)
533
     */
534
    private static function calculateOptimalNetworkSize(int $hostCount): int
535
    {
536
        // Special case: 1 host needs /32
537
        if ($hostCount === 1) {
37✔
538
            return 32;
2✔
539
        }
540

541
        // Special case: 2 hosts can use /31 (RFC 3021)
542
        if ($hostCount === 2) {
35✔
543
            return 31;
2✔
544
        }
545

546
        // For 3+ hosts, we need to account for network and broadcast addresses
547
        // Usable hosts = 2^(32-prefix) - 2
548
        // So we need: 2^(32-prefix) >= hostCount + 2
549
        // Therefore: 32 - prefix >= log2(hostCount + 2)
550
        // prefix <= 32 - ceil(log2(hostCount + 2))
551

552
        $totalAddressesNeeded = $hostCount + 2;
33✔
553
        $bitsNeeded = (int) \ceil(\log($totalAddressesNeeded, 2));
33✔
554
        $networkSize = 32 - $bitsNeeded;
33✔
555

556
        // Ensure network size is valid
557
        if ($networkSize < 1) {
33✔
558
            $networkSize = 1;
1✔
559
        }
560

561
        return $networkSize;
33✔
562
    }
563
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc