# pan-os-php User Guide

`pan-os-php` is a PHP framework and command-line toolset published by Palo Alto Networks for working with PAN-OS firewall and Panorama configurations at scale. It loads an offline XML configuration file (or pulls one live over the XML API), exposes every object — addresses, services, rules, zones, security profiles, interfaces, routing, schedules, certificates — as native PHP classes, and ships a single front-end utility (`pan-os-php`) that dispatches forty-plus subcommands ("UTIL types") covering object editing, rule editing, mergers, BPA generation, software downloads, log retrieval, configuration diffs, IronSkillet pushes, and dozens of other day-to-day Palo Alto admin tasks.

This guide is for the engineers who run those subcommands from a terminal: how to install the framework, how to point it at a Panorama or firewall, how to pick the right `type=` and `actions=`, and how to recover when things go wrong.

---

## 1. Quick Start

`pan-os-php` is invoked as `pan-os-php type=<subcommand>` (or via Docker as `docker run ... type=<subcommand>`) followed by query arguments and actions. The fastest way to be running an action against a sample config in under thirty seconds is Docker — no host PHP, no install scripts, no shell rc edits.

### Try it in 30 seconds with Docker

```bash
docker run --rm -v "$PWD":/share swaschkut/pan-os-php:latest \
  type=address \
  in=/share/panorama-config.xml \
  'filter=(object is.unused.recursive)' \
  actions=display
```

```text
***********************************************
*********** pan-os-php.php UTILITY **************

 - PAN-OS-PHP version: 2.1.20 [UNIX] [8.1.2]

***********************************************
*********** Reading configuration from file ****
***********************************************

 - object 'lab-mgmt-host' is unused (recursive)
 - object 'old-vpn-peer' is unused (recursive)
 - object 'decommissioned-printer' is unused (recursive)

**** PROCESSING OF 3 OBJECTS DONE ****
```

That single command pulls the published image, mounts your current directory at `/share`, runs the dispatcher inside the container, finds every never-referenced address object, and prints them. No file is written; nothing is changed.

> [!TIP]
> If you do not have a Panorama config handy, the repo ships sample configs under `examples/sample-configs/` — clone the repo, `cd` into it, and re-run with `in=/share/examples/sample-configs/<file>.xml`. Once you are comfortable, alias the command permanently: the shipped `utils/alias.sh` defines `pa_docker-panosphp` as `docker run --name panosphp --rm -v $PWD:/share -it swaschkut/pan-os-php:latest`, so `pa_docker-panosphp type=address listactions` becomes the entire invocation.

### Key features

- Single dispatcher (`utils/pan-os-php.php`) with **47 supported UTIL types** including `address`, `service`, `tag`, `rule`, `zone`, `interface`, `routing`, `schedule`, `application`, `threat`, `securityprofile`, `securityprofilegroup`, `device`, `dhcp`, `certificate`, `static-route`, `address-merger`, `addressgroup-merger`, `service-merger`, `servicegroup-merger`, `tag-merger`, `rule-merger`, `custom-url-category-merger`, `override-finder`, `diff`, `upload`, `xml-issue`, `appid-enabler`, `config-size`, `download-predefined`, `register-ip-mgr`, `userid-mgr`, `xml-op-json`, `bpa-generator`, `playbook`, `ironskillet-update`, `maxmind-update`, `software-remove`, `software-download`, `software-preparation`, `traffic-log`, `system-log`, `gratuitous-arp`, `license`, `config-download-all`, `spiffy`, `config-commit`, `protocoll-number-download`, `html-merger`, `tsf`, `xpath`, `key-manager`, `ssh-connector`, `custom-report`, `gcp`, `vendor-migration`, `appid-toolbox`, `rule-compare`, `stats`, and `util_get-action-filter`.
- **220+ named actions** across object and rule types — every rename, tag manipulation, IP swap, profile assignment, and bulk merge is a one-liner.
- **Filter language (`filter=`)** that reads like English — `'(from has external) and (to has Trust)'` — to scope which objects an action touches.
- Works **offline against an exported XML config** (`in=config.xml`) or **live over the API** (`in=api://10.0.0.2` or `in=api://[email removed]`). API keys are cached in `~/.panconfkeystore` after the first login.
- Generates **set-format command output** for change reviews (`outputformatset=changes.txt`) and supports a preview model that loads, evaluates, and reports without writing anything back.
- Ships **Docker images** for amd64 and arm64 (`swaschkut/pan-os-php:latest`), plus install scripts for **Ubuntu 20.04**, **Ubuntu 22.04**, **CentOS 7**, and **macOS**.

### Prerequisites

PHP 7.4 or later (8.1 is the upper bound the project tests against), with the `json`, `curl`, `dom`, `mbstring`, `bcmath`, `mysqli`, and `yaml` extensions available; `git`; bash 5; and a Palo Alto Networks firewall or Panorama configuration (XML file or live device). Docker users skip every PHP requirement — the image bundles everything.

### What to try next

- Section 2 (Installation) — pick the install method that matches your OS. Docker is recommended for everyone trying `pan-os-php` for the first time.
- Section 4 (Core Usage) — the `type=` + `filter=` + `actions=` model explained from scratch.
- Section 5 (Common Tasks) — copy-paste recipes for the discovery and reduction work that drives most real-world use.

---

## 2. Installation

`pan-os-php` is **not** a Composer-installable library in the conventional sense; it is a fixed-path source tree that PHP loads from `include_path`. The supported native install methods all converge on the same outcome: the repo lives at `/tools/pan-os-php`, that path is on PHP's `include_path`, and the alias `pan-os-php` evaluates to `php -r "require_once 'utils/pan-os-php.php';" $@`. Docker bypasses all of that — the image already has the right layout baked in.

### System requirements

| Component | Minimum | Tested upper bound |
|---|---|---|
| PHP | 7.4.0 | 8.1 (composer.json), 8.x runtime |
| PHP extensions | `json`, `curl`, `dom`, `mbstring`, `bcmath`, `mysqli`, `yaml` | — |
| Bash | 5.0 (for `pan-os-php` autocompletion) | — |
| OS | Ubuntu 20.04 / 22.04, CentOS 7, macOS, Windows via Docker | — |
| Memory | 14 GB PHP `memory_limit` is set inside the framework (`ini_set("memory_limit", "14512M")`) | — |
| Git | Any 2.x | — |
| Python 3 (optional, used by some helper scripts) | 3.x with `pandas`, `xlsxwriter`, `netaddr`, `requests` | — |

> [!IMPORTANT]
> The framework calls `ini_set("memory_limit", "14512M")` and `set_time_limit(0)` at boot. Loading a multi-thousand-rule Panorama config can genuinely consume tens of GB of resident PHP memory; size your jump host accordingly. The framework also calls `date_default_timezone_set('UTC')` unconditionally — if you read timestamps off a generated report, treat them as UTC.

### 2.1 Quick install (recommended for trying it)

The published Docker image is the lowest-friction path. Zero PHP setup on your host, identical behaviour on Linux / macOS / Windows / WSL2 / a CI runner.

```bash
docker run --rm -v "$PWD":/share -it swaschkut/pan-os-php:latest type=address listactions
```

The image entrypoint (`docker/entrypoint.sh`) is literally:

```bash
exec php -r "require_once('/tools/pan-os-php/utils/pan-os-php.php');" "$@"
```

so anything you would pass to `pan-os-php` you pass after the image name. Mount your XML config or working directory at `/share` and reference it with `in=/share/panorama-config.xml`; outputs written to `/share/...` end up in your host directory.

**Make the alias permanent.** The shipped `utils/alias.sh` defines a convenience alias:

```bash
alias pa_docker-panosphp='docker run --name panosphp --rm -v $PWD:/share -it swaschkut/pan-os-php:latest'
```

Drop that line into `~/.bashrc` (or `source` it from the cloned repo with `source utils/alias.sh`), then every invocation simplifies to:

```bash
pa_docker-panosphp type=address in=/share/panorama-config.xml \
  'filter=(object is.unused.recursive)' actions=display
```

> [!TIP]
> The `--name panosphp` flag means a stuck or crashed container leaves a dead container behind under that fixed name, which the `--rm` cleans up — safe to re-run. If you want concurrent invocations, drop `--name` (Docker will auto-name them) and keep `--rm` for cleanup.

Both `swaschkut/pan-os-php-amd:latest` and `swaschkut/pan-os-php-arm:latest` are published; the manifest `swaschkut/pan-os-php:latest` selects the right architecture automatically. A `:develop` tag tracks the development branch.

### 2.2 Native install on Linux (production)

For a long-lived jump host running scheduled jobs, the native install is faster per-invocation (no container start cost) and lets you keep your editor / IDE / `git` working in the same shell.

#### Ubuntu 22.04 (preferred native target)

```bash
sudo bash /path/to/repo/install_script_ubuntu22.sh
```

The script installs `php8.1`, `php8.1-curl`, `php8.1-dom`, `php8.1-mbstring`, `php8.1-bcmath`, `php8.1-yaml`, `python3` with `pandas`/`xlsxwriter`/`netaddr`/`requests`, `jq`, and `git`; clones the repo to `/tools/pan-os-php`; appends `include_path = '/tools/pan-os-php'` to `/etc/php/8.1/cli/php.ini`; and symlinks the bash autocompletion script into `/usr/share/bash-completion/completions/pan-os-php`. After it finishes, run the per-user step exactly once for each user account that will use the tool:

```bash
sh /tools/pan-os-php/set_alias_usage.sh
```

That appends `source /tools/pan-os-php/utils/alias.sh` and the autocompletion stub to your `~/.bashrc`, then verifies the install with the standard one-liner:

```bash
php -r "require('/tools/pan-os-php/lib/pan_php_framework.php');print 'PAN-OS-PHP LIBRARY - OK INSTALL SUCCESSFUL\n';"
```

```text
PAN-OS-PHP LIBRARY - OK INSTALL SUCCESSFUL
```

#### Ubuntu 20.04

`install_script_ubuntu20.sh` is identical except it pulls `php7.4` and writes its `include_path` line to `/etc/php/7.4/cli/php.ini`. Use it on hosts you cannot upgrade to 22.04.

### 2.3 macOS

```bash
cd /path/to/pan-os-php
bash mac_set_path_variable.sh
```

The macOS script reads the `Loaded Configuration File` path out of `php -i`, appends `include_path = '<repo>'` to that file, then registers `source <repo>/utils/alias.sh` into `~/.profile`, `~/.bash_profile`, **and** `~/.zshrc`. After it finishes, open a new shell and run the verification one-liner from section 2.5.

### 2.4 CentOS 7

```bash
sudo bash /path/to/repo/install_script_centos7.sh
```

The CentOS path is more involved — it adds `epel-release`, the Remi repo (`remi-php74`), builds bash 5 from source (CentOS 7 ships bash 4), and changes the user's login shell with `chsh -s /usr/local/bin/bash`. After the script finishes, run `chsh -s /usr/local/bin/bash` for your own user before opening a new shell.

> [!WARNING]
> The CentOS 7 installer rewrites `/etc/shells` and changes the login shell. Read the script before running it on a server with strict change control. There is also an older companion script (`install_script_centos7_old.sh`) that targets PHP 5.6 via the `remi-php56` repo — it is retained for legacy use only and should not be used on new installs.

### 2.5 Verify the install

After a native install, this single line proves the autoloader, the framework, the API connector, and `phpseclib` are all on the include path and parseable:

```bash
php -r "require('/tools/pan-os-php/lib/pan_php_framework.php');print 'PAN-OS-PHP LIBRARY - OK INSTALL SUCCESSFUL\n';"
```

```text
PAN-OS-PHP LIBRARY - OK INSTALL SUCCESSFUL
```

Then check the dispatcher itself:

```bash
pan-os-php version
```

```text
 - PAN-OS-PHP version: 2.1.20 [UNIX]
 - /tools/pan-os-php/utils
 - PHP version: 8.1.2
```

For the Docker path, the equivalent verification is:

```bash
docker run --rm swaschkut/pan-os-php:latest version
```

### Updating

```bash
bash /tools/pan-os-php/update_pan-os-php.sh
```

The updater stashes any local edits, runs `git clean -f`, pulls `origin main` over HTTPS with TLS verification disabled (because the script is meant to be runnable on locked-down jump hosts behind MITM proxies), and re-runs `git submodule update --remote`. Docker users update with `docker pull swaschkut/pan-os-php:latest`.

### Removing the path/alias

```bash
bash /path/to/pan-os-php/remove_path_variable.sh
```

That script removes the `include_path` line from your `php.ini` and the `source .../alias.sh` line from `~/.profile`, `~/.bash_profile`, and `~/.zshrc`. It does **not** delete the source tree — `rm -rf /tools/pan-os-php` if you want it gone for good.

### Install troubleshooting

#### Error: `*** ERROR **** PAN-OS-PHP requires PHP version >= 5.5`

The framework refuses to load on PHP < 5.5. In practice, raise the floor: install PHP 7.4 or later via your distro's package manager, or use the Docker image (which already has 8.1).

#### `pan-os-php: command not found` after install

Open a new shell (the alias is added to `~/.bashrc`), or `source ~/.bashrc` in the current one. The alias itself is `alias pan-os-php='php -r "require_once 'utils/pan-os-php.php';" $@'`. If you skipped `set_alias_usage.sh`, run it now.

#### `Warning: include(...): failed to open stream` from any UTIL

PHP cannot find the framework files. Check `php -i | grep include_path`. If it does not contain the install path (default `/tools/pan-os-php`), append `include_path = '/tools/pan-os-php'` to the `php.ini` reported by `php -i | grep "Loaded Configuration File"`.

---

## 3. Configuration

`pan-os-php` does **not** read a config file. It is configured by:

1. **CLI arguments** parsed by `PH::processCliArgs()` — every `key=value` pair on the command line becomes an entry in `PH::$args`.
2. **Aliases** in `utils/alias.sh` that rebind shorthand command names (`pa_docker-panosphp`, `pa_ckp-exclude`, `pa_csv-import`, `pa_ike`, `pa_config-reset`, `pa_get-system-user-info`).
3. **Environment variables** read by the framework: `HOME`, `USERPROFILE`, `HOMEDRIVE`, `HOMEPATH` (used to locate `~/.panconfkeystore`) and `TERM` (used to enable bold ANSI output).
4. **The hidden API keystore** at `$HOME/.panconfkeystore` — a file that maps `serial@host` to API key once you have logged in once. Disable with `shadow-apikeynosave`.

### Standard arguments accepted by every UTIL

These are wired up in `UTIL::supportedArguments()` and apply to nearly every `type=` invocation:

| Argument | Purpose | Example |
|---|---|---|
| `in=<file\|api>` | Source config: filename, `api://IP`, or `api://serial@panorama` | `in=api://10.0.0.5` |
| `out=<file>` | Output file when `in` is a file | `out=after.xml` |
| `location=<scope>` | VSYS or DG to scope the run; defaults to `shared` (Panorama) or `vsys1` (firewall) | `location=any`, `location=DG-Edge:includechilddgs` |
| `template=<name>` | Limit Panorama processing to a specific template | `template=Edge-Stack` |
| `filter=<RQuery>` | Filter expression (see Section 4) | `'filter=(object is.unused.recursive)'` |
| `actions=<action>[:arg1[,arg2]]` | What to do per matched object — slash separates multiple | `actions=tag-Add:RetiredQ4` |
| `listactions` | Print every supported action and its argument schema for this `type` | `pan-os-php type=rule listactions` |
| `listfilters` | Print every supported filter operator for this `type` | `pan-os-php type=address listfilters` |
| `stats` | Print object/rule counts after the run | `stats` |
| `loadplugin=<file>` | Load a custom action/filter plugin | `loadplugin=examples/example_plugin_rule_display_source_destination.php` |
| `apitimeout=<seconds>` | API call timeout, default 60 | `apitimeout=300` |
| `auditcomment=<text>` | Override the default audit comment when committing via API | `auditcomment="JIRA-1234"` |
| `outputformatset[=file]` | Emit PAN-OS `set` commands for the change | `outputformatset=changes.set` |
| `projectfolder=<dir>` | Place all generated outputs in this folder | `projectfolder=runs/2026-04-28/` |
| `loadpanoramapushedconfig` | When loading from a managed firewall, also pull in Panorama-pushed objects | `loadpanoramapushedconfig` |
| `cycleconnectedfirewalls` | For Panorama API: iterate every connected firewall (used by `bpa-generator`, `override-finder`) | `cycleconnectedfirewalls` |
| `git` | Initialise a Git repo around the input file and commit before/after | `git` |
| `debugapi` | Print every API call as it happens | `debugapi` |
| `debugloadtime` | Print the time each load phase takes | `debugloadtime` |
| `help` | Show usage for the current `type=` and exit | `help` |

### `shadow-` flags (advanced)

These toggle protocol- and output-level behaviours. They are real flags, exposed by `listactions`/`help`, but you rarely need them day-to-day.

| Flag | Purpose |
|---|---|
| `shadow-disableoutputformatting` | Skip `DOMDocument` output cleanup; keep raw XML |
| `shadow-enablexmlduplicatesdeletion` | If duplicate objects are seen, keep only one |
| `shadow-ignoreinvalidaddressobjects` | Tolerate address objects with no value/type |
| `shadow-apikeyhidden` | POST the API key (PAN-OS 9.0+) instead of putting it in the URL |
| `shadow-apikeynohidden` | Force API key in URL (PAN-OS < 9.0) |
| `shadow-apikeynosave` | Do **not** persist the API key to `~/.panconfkeystore` |
| `shadow-displaycurlrequest` | Print the raw curl invocation for every API call |
| `shadow-reducexml` | Strip whitespace from output XML |
| `shadow-json` | Emit stdout as a JSON document instead of human text (BETA) |
| `shadow-nojson` | Force human text output even if `shadow-json` was set elsewhere |
| `shadow-displayxmlnode` | Include matched XML element content in `actions=display` output |
| `shadow-saseapiqa` | Use SASE QA endpoints instead of production |

> [!NOTE]
> The `outputformatset` argument is the safest way to preview a destructive change. It loads the input, evaluates every action, and writes the equivalent PAN-OS `set` commands to a file — paste those commands into the firewall CLI yourself if the diff looks right.

### Where things live on disk

| Path | Purpose |
|---|---|
| `/tools/pan-os-php/utils/pan-os-php.php` | Single CLI entry point |
| `/tools/pan-os-php/utils/lib/*.php` | UTIL implementations (`RULEUTIL.php`, `STATSUTIL.php`, `MERGER.php`, `RULEMERGER.php`, `KEYMANGER.php`, `BPAGENERATOR.php`, `OVERRIDEFINDER.php`, etc.) |
| `/tools/pan-os-php/utils/common/*.php` | CallContext + Action implementations |
| `/tools/pan-os-php/lib/` | Framework library (`pan_php_framework.php` is the entry the framework loads) |
| `/tools/pan-os-php/iron-skillet/` | Bundled Palo Alto IronSkillet day-1 templates (PAN-OS 8.1, 9.1, 10.0, 10.1, 10.2, 11.0) |
| `/tools/pan-os-php/migration/parser/` | Vendor migration parsers (Cisco ASA, Cisco ISR, Cisco Switch, NetScreen, SonicWall, Sophos, Fortinet, Juniper SRX, CheckPoint, CheckPoint R80, Huawei, StoneSoft, Sidewinder, pfSense) |
| `~/.panconfkeystore` | Cached API keys (one per device) |

For the full list of every config file, every supported argument with type and default, and the deep XML-API protocol details, see the Administrator & Developer Guide.

---

## 4. Core Usage

Every `pan-os-php` run follows the same five-stage pipeline, regardless of which `type=` you choose:

1. **Parse arguments** (`PH::processCliArgs()`).
2. **Load config** — `in=<file>` reads XML off disk; `in=api://...` pulls it via the XML API and caches the API key.
3. **Filter** — apply `filter=<RQuery>` to narrow the working set.
4. **Act** — run each `actions=<name>` in order. `/` separates multiple actions (`actions=tag-Add:Old/disabled-Set`).
5. **Save** — write changes back (`out=<file>` for offline mode, push via API for online mode), and optionally emit `set` commands or stats.

### Picking a `type=`

The `type=` argument selects the UTIL class. Group them by job:

#### Object editors (use `type=<object>`)

| Type | What it edits |
|---|---|
| `address` | Address objects and address groups |
| `service` | Service objects and service groups |
| `tag` | Tags |
| `schedule` | Schedule objects |
| `application` | Application objects (custom apps, app filters, app groups) |
| `threat` | Threat exceptions (vulnerability, anti-spyware) |
| `securityprofile` | URL filtering, AV, anti-spyware, vulnerability, file blocking, wildfire profiles |
| `securityprofilegroup` | Security profile groups |

#### Network editors

| Type | What it edits |
|---|---|
| `zone` | Zones |
| `interface` | Ethernet, aggregate-ethernet, loopback, tunnel, vlan, virtual-wire interfaces |
| `virtualwire` | Virtual wires |
| `routing` | Virtual routers and static routes |
| `static-route` | Just static routes |
| `dhcp` | DHCP servers / relays |
| `certificate` | Certificates |

#### Rule editors

| Type | What it edits |
|---|---|
| `rule` | All rule types: security, NAT, decryption, app-override, captive-portal, authentication, PBF, QoS, DoS, tunnel-inspection, SD-WAN, packet-broker, default-security |
| `rule-merger` | Merges adjacent / shadowed rules |
| `rule-compare` | Diffs two rule sets |

#### Object/group mergers

| Type | What it does |
|---|---|
| `address-merger` | Collapses duplicate address objects |
| `addressgroup-merger` | Collapses duplicate address groups |
| `service-merger` | Collapses duplicate service objects |
| `servicegroup-merger` | Collapses duplicate service groups |
| `tag-merger` | Collapses duplicate tags |
| `custom-url-category-merger` | Collapses duplicate custom URL categories |

#### Operations

| Type | What it does |
|---|---|
| `device` | Manages Panorama-managed devices |
| `bpa-generator` | Generates Palo Alto Best Practice Assessment input |
| `config-size` | Reports size/depth metrics for the loaded config |
| `config-commit` | Commits changes via API |
| `config-download-all` | Downloads every running/candidate config from a Panorama |
| `diff` | Diffs two configs |
| `override-finder` | Finds firewalls whose candidate config has drifted from the Panorama template |
| `xml-issue` | Reports XML structure issues |
| `xml-op-json` | Runs an XML-API op command and returns JSON |
| `xpath` | Runs an XPath query against a config |
| `upload` | Uploads a config to a device |
| `key-manager` | Manages cached API keys (`~/.panconfkeystore`) |
| `register-ip-mgr` | Registered-IP/dynamic address group manager |
| `userid-mgr` | User-ID mapping injector |
| `appid-enabler` | Promotes app-ID-aware versions of app-default services |
| `appid-toolbox` | Long-running App-ID activation pipeline |
| `playbook` | Runs an ordered playbook of UTIL invocations |
| `software-download` / `software-remove` / `software-preparation` | PAN-OS software image lifecycle |
| `traffic-log` / `system-log` | Log retrieval |
| `gratuitous-arp` | Triggers gratuitous ARP from a firewall |
| `license` | License operations |
| `spiffy` | SPIFFY policy migration helper |
| `gcp` | GCP integration helper |
| `ironskillet-update` | Refreshes the bundled IronSkillet templates |
| `maxmind-update` | Refreshes the bundled MaxMind GeoIP database |
| `protocoll-number-download` | Refreshes the IANA protocol number list |
| `download-predefined` | Downloads PAN-OS predefined objects |
| `html-merger` | Merges HTML reports |
| `tsf` | Tech Support File helper |
| `ssh-connector` | SSH connector for non-XML-API operations |
| `custom-report` | Custom-report exporter |
| `vendor-migration` | Vendor migration runner (drives the `migration/parser/<vendor>/` modules) |
| `stats` | Pure read-only — print statistics about the loaded config |
| `util_get-action-filter` | Dumps every supported action and filter as JSON |

### Picking a `filter=`

`filter=` accepts an RQuery expression — boolean logic over per-type operators. Run `pan-os-php type=<X> listfilters` to print every supported field/operator combo. Common idioms:

```bash
# All address objects whose name contains "lab"
'filter=(name regex /lab/)'

# Unused address objects (recursive: includes objects only used by other unused groups)
'filter=(object is.unused.recursive)'

# Security rules from any external zone to the Trust zone, disabled
'filter=((from has external) and (to has Trust) and (rule is.disabled))'

# Address objects whose IP value is fully or partially inside an aggregate
'filter=(value is.partially.or.fully.included.in.list,10.0.0.0/8)'
```

Filters compose with `and`, `or`, `!` (NOT), and parentheses — and they always quote the whole expression to keep the shell out of it.

### Picking `actions=`

`actions=` runs **after** the filter. Multiple actions chain with `/`. Examples:

```bash
# Tag every unused address object as Retired
pan-os-php type=address \
  in=panorama.xml \
  'filter=(object is.unused)' \
  actions=tag-Add:Retired

# Rename every address object that starts with "OLD-" by stripping the prefix
pan-os-php type=address \
  in=panorama.xml \
  'filter=(name regex /^OLD-/)' \
  actions=name-removePrefix:OLD-

# Disable every never-hit rule and append a description
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=(rule is.unused.fast)' \
  actions=disabled-Set/description-Append:" - never used"
```

### Loading from disk vs. from a live device

| Source | `in=` value | What happens |
|---|---|---|
| Saved Panorama running config XML | `in=panorama.xml` | XML loaded directly. `out=` required to save changes. |
| Firewall running config XML | `in=firewall.xml` | XML loaded directly. `out=` required. |
| Live firewall (key flow) | `in=api://10.0.0.2` | Prompts for username/password the first time, requests an API key, stores it in `~/.panconfkeystore`, then reuses it. Changes pushed straight to candidate config. |
| Live firewall (key already cached) | `in=api://10.0.0.2` | Reuses cached key. |
| Specific firewall managed by a Panorama | `in=api://[email removed]` | Tunnels through Panorama to that serial. |
| SASE | `in=sase-api://...` | Uses the SASE API connector (limited UTIL coverage; see `key-manager` for setup) |

> [!TIP]
> Before running anything destructive against a live device, save a snapshot offline first (`in=api://... out=snapshot.xml`) and iterate on the snapshot. Combine that with `outputformatset=preview.set` to see exactly which CLI commands would have been issued.

> [!IMPORTANT]
> Quote your filter expressions. The shell will eat parentheses, pipes, and spaces otherwise. Wrap the whole `filter=...` argument in single quotes: `'filter=(from has external)'`.

### Output channels

| Argument | Effect |
|---|---|
| `out=after.xml` | Save edited config (offline mode only) |
| `outputformatset=changes.set` | Write equivalent PAN-OS `set` commands |
| `projectfolder=runs/today/` | Place every generated file under this folder |
| `stats` | Print before/after object/rule counts |
| `shadow-json` | Emit machine-readable JSON instead of a human report |

### Discovering what a UTIL can do

`listactions` and `listfilters` are your friends:

```bash
pan-os-php type=rule listactions
```

```text
* delete                     |                          |              |
==================================================================================================
* tag-Add                    |  #1 tagName:string       | <empty>      |
==================================================================================================
* tag-Add-Force              |  #1 tagName:string       | <empty>      |
==================================================================================================
* enabled-Set                |                          |              |
==================================================================================================
* disabled-Set               |                          |              |
... [hundreds more rows]
```

```bash
pan-os-php type=rule listfilters
```

```text
* name
    - regex                                : matches name against a PCRE regex
    - eq                                   : exact name match
    - contains                             : substring match
* from
    - has                                  : contains a zone with this name
    - has.regex                            : zone name matches regex
... [dozens more]
```

The `util_get-action-filter` UTIL writes both lists for every type to `utils/lib/util_action_filter.json` — useful when you want to grep across types instead of running `listactions` for each one.

---

## 5. Common Tasks

This section is a copy-paste cookbook for the two jobs that fill most of an admin's week with `pan-os-php`: **discovery** (find what is there, what is unused, what shadows what, what overrides what) and **reduction** (merge duplicates, drop unused, normalize naming, move objects to the right scope). Each recipe follows the same structure: **Goal · Prerequisites · Steps · Verify · If it fails**.

Two safety patterns repeat throughout. **Burn them into muscle memory before any destructive run:**

- **Dry-run first.** For object and rule UTILs that accept `actions=`, replace the destructive action with `actions=display` to see what the filter matches. For mergers, pass `actions=display` (the merger framework treats `display` as "compute the merge plan, print it, write nothing"). Then re-run with the destructive action only after the diff looks right.
- **Snapshot live devices.** When `in=api://...`, immediately stop and re-run with `in=api://... out=snap.xml` so you have a known-good offline copy. Iterate against `snap.xml` until you trust the change, then push it.

> [!CAUTION]
> Mergers and bulk renames rewrite references **everywhere** — security rules, NAT rules, address groups, IKE gateway peer addresses, route-map next-hops, and any other place an object is mentioned by name. Always preview with `actions=display` and (where supported) `outputformatset=preview.set` before the real run. See GitHub issue #793 for an example of how merger-related filter behaviour can drift between releases.

### 5.1 — Discovery: find every unused address object on a firewall

**Goal:** flag dead addresses on a single firewall so they can be retired.

**Prerequisites:** A saved firewall config XML, or API access to the live device.

**Steps:**

```bash
pan-os-php type=address \
  in=firewall-running.xml \
  location=any \
  'filter=(object is.unused)' \
  actions=display
```

For a CSV-friendly export instead of human text, swap `actions=display` for `actions=exportToExcel:unused-addresses.xlsx,WhereUsed|UsedInLocation`.

**Verify:** every line in stdout corresponds to an address object name. The trailing summary reads `**** PROCESSING OF N OBJECTS DONE ****`. Cross-check N against `pan-os-php type=address in=firewall-running.xml stats` to know what fraction of the address store is dead.

**If it fails:** if you get `cannot find vsys/device-group <name>`, the input is from a different scope than `location=` expects. Add `location=any` so every VSYS / DG is scanned. If the count looks suspiciously low, switch to recipe 5.2 (`is.unused.recursive`) — non-recursive misses objects that are referenced *only* by another unused group.

### 5.2 — Discovery: find every unused address object on a Panorama (recursive)

**Goal:** flag dead addresses across an entire Panorama, including ones whose only reference is *another* unused group.

**Prerequisites:** A saved Panorama config XML.

**Steps:**

```bash
pan-os-php type=address \
  in=panorama-running.xml \
  location=any \
  'filter=(object is.unused.recursive)' \
  actions=display
```

The `recursive` matters because of how Panorama device-group inheritance works: an address object can sit in `shared` while its only reference is `addr-group-A` (also in `shared`), which is itself only referenced by `addr-group-B` (in a child DG), which is itself unused. Plain `is.unused` only flags `addr-group-B`; `is.unused.recursive` walks the chain and flags all three.

**Verify:** spot-check a few of the flagged objects in the Panorama GUI under **Objects > Addresses** — the **Used in** column should be empty.

**If it fails:** see the open `is.unused.recursive` correctness issue (#791 in the project tracker). For high-stakes deletions, run both `is.unused` and `is.unused.recursive`, diff the results, and review anything that appears in only one set.

### 5.3 — Discovery: find every unused service object

**Goal:** the same dead-object hunt, but for service objects.

**Steps:**

```bash
pan-os-php type=service \
  in=panorama.xml \
  location=any \
  'filter=(object is.unused)' \
  actions=display

pan-os-php type=service \
  in=panorama.xml \
  location=any \
  'filter=(object is.unused)' \
  actions=exportToExcel:unused-services.xlsx,WhereUsed
```

**Verify:** the `WhereUsed` column on the Excel export is empty for every flagged row.

**If it fails:** services with the same `port`/`protocol` but different names sometimes appear unused on one side and used on the other. Run recipe 5.12 (`service-merger`) afterwards to consolidate name-different / value-identical services first, then re-discover.

### 5.4 — Discovery: find every unused tag

**Goal:** flag tags nobody is referencing so they can be deleted from the legend.

**Steps:**

```bash
pan-os-php type=tag \
  in=panorama.xml \
  location=any \
  'filter=(object is.unused)' \
  actions=display
```

**Verify:** the count printed at the end matches the count you see in **Objects > Tags > Unused** in the Panorama GUI (refresh required).

**If it fails:** `is.unused` for tags ignores rule `group-tag` references on some PAN-OS versions. Cross-check with a `type=rule` pass: `pan-os-php type=rule in=panorama.xml 'filter=(group-tag is.set)' actions=display` — any group-tag listed there should NOT appear in the unused-tag set.

### 5.5 — Discovery: find every rule that has never been hit

**Goal:** identify dead security rules — rules with zero traffic since the device started counting.

**Prerequisites:** API access to the firewall or Panorama (hit-count data only exists on live devices). For Panorama: identify whether the rules of interest are pre-rules or post-rules.

**Steps:**

```bash
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=(rule is.unused.fast)' \
  actions=display
```

For Panorama:

```bash
pan-os-php type=rule \
  in=api://panorama.local \
  panoramaPreRules \
  'filter=(rule is.unused.fast)' \
  actions=display
```

(Repeat with `panoramaPostRules` for the post-rulebase.)

To get hit-count detail with the rule list, add `actions=rule-hit-count-show`:

```bash
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=(rule is.unused.fast)' \
  actions=display/rule-hit-count-show
```

**Verify:** the `rule-hit-count-show` output prints `hit-count: 0` for every flagged rule, plus `last-hit-timestamp` and `rule-creation-timestamp`. A rule created yesterday with hit-count 0 is not a great candidate for deletion; one created two years ago with hit-count 0 is.

**If it fails:** fresh installs and devices that recently rebooted have empty hit counters; the filter will correctly match every rule until traffic catches up. Wait at least one full business cycle (typically a week) after a reboot before treating `is.unused.fast` as authoritative.

### 5.6 — Discovery: find rules unused beyond a date threshold

**Goal:** narrow the "never hit" set to "not hit in N days" by combining the rule filter with the hit-count operator.

**Steps:**

```bash
# Rules whose hit count is < 1 (i.e., zero hits in the API window)
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=(hit-count.fast < 1)' \
  actions=display
```

For a more targeted "haven't been hit in 90 days" approach, follow the hit-count export pattern — run `rule-hit-count-show` for every rule in scope, capture the `last-hit-timestamp` field, and post-filter externally. The `exportToExcel` action with `additionalFields=HitCount` gives you a spreadsheet you can sort:

```bash
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=(rule is.enabled)' \
  actions=exportToExcel:hit-counts.xlsx,HitCount
```

**Verify:** open the spreadsheet, sort by `last-hit`, and review the rules whose last hit predates your cutoff date.

**If it fails:** see the comment under recipe 5.5 about reboots. Also note that hit-count behaviour changes with PAN-OS rule-modification events: editing any field (description, tag, action) on some PAN-OS versions resets the rule-creation-timestamp and may reset the hit counter.

### 5.7 — Discovery: who references this object?

**Goal:** answer the perennial question — "if I delete `addr-prod-db`, what breaks?"

**Steps:**

```bash
pan-os-php type=address \
  in=panorama.xml \
  location=any \
  'filter=(name eq addr-prod-db)' \
  actions=displayReferences
```

The `displayReferences` action lists every place in the config — every rule (security, NAT, decryption, etc.), every group, every interface, every IKE gateway — that mentions the object by name.

For a tag-based cohort, swap the filter for the broader cohort and the action stays the same:

```bash
pan-os-php type=address \
  in=panorama.xml \
  location=any \
  'filter=(tag has Production)' \
  actions=displayReferences
```

**Verify:** the count of references printed equals what the Panorama GUI shows in the **Used In** column for that object.

**If it fails:** if `displayReferences` returns nothing for an object you can clearly see in a rule, the object name is being matched case-sensitively — confirm with `'filter=(name eq.nocase addr-prod-db)'` (operator `eq.nocase` does case-insensitive match for address objects).

### 5.8 — Discovery: address objects scoped by tag, group, or supernet

**Goal:** assemble lists of objects matching real-world facets — "all production-tagged hosts", "all members of the DMZ-Hosts group", "everything in the 10.0.0.0/8 supernet".

**Steps:**

```bash
# Everything tagged Production
pan-os-php type=address \
  in=panorama.xml location=any \
  'filter=(tag has Production)' \
  actions=display

# Everything inside an address group (recursive — picks up nested groups)
pan-os-php type=address \
  in=panorama.xml location=any \
  'filter=(object is.recursive.member.of,DMZ-Hosts)' \
  actions=display

# Everything inside a /8 supernet (only matches ip-netmask and ip-range objects)
pan-os-php type=address \
  in=panorama.xml location=any \
  'filter=(value is.partially.or.fully.included.in.list,10.0.0.0/8)' \
  actions=display

# Everything that is an IPv6 address
pan-os-php type=address \
  in=panorama.xml location=any \
  'filter=(object is.ipv6)' \
  actions=display
```

**Verify:** the count of matched objects matches what you would manually count from the GUI filter.

**If it fails:** if `is.partially.or.fully.included.in.list` returns nothing or the wrong set, check whether you are running into the regression tracked in #793 — fall back to running `is.partially.included.in.list` and `is.fully.included.in.list` separately and combining the results with `or`.

### 5.9 — Discovery: rules + zones + actions to a CSV/Excel for review

**Goal:** dump every security rule with its zones and action into a spreadsheet for offline review (audit, peer review, change-control attachment).

**Steps:**

```bash
pan-os-php type=rule \
  in=panorama.xml \
  location=any \
  panoramaPreRules \
  actions=exportToExcel:rules-prerule.xlsx,ResolveAddressSummary|ResolveServiceSummary|HitCount
```

The `additionalFields` after the filename are pipe-separated and supported choices include `ResolveAddressSummary`, `ResolveServiceSummary`, `ResolveServiceAppDefaultSummary`, `ResolveApplicationSummary`, `ResolveScheduleSummary`, `ApplicationSeen`, and `HitCount`.

**Verify:** open the resulting `.xlsx`. Every row should have populated `from`, `to`, `source`, `destination`, `service`, and `action` columns plus the resolved-summary columns you asked for.

**If it fails:** the exporter relies on `python3-xlsxwriter` (installed by the Ubuntu install scripts). If you used Docker, the image already has it; if you did a custom install, `pip3 install xlsxwriter pandas`.

### 5.10 — Discovery: find Panorama-template overrides on managed firewalls

**Goal:** find every managed firewall whose candidate config has drifted from the Panorama template.

**Steps:**

```bash
pan-os-php type=override-finder \
  in=api://panorama.local \
  cycleconnectedfirewalls
```

The `override-finder` UTIL is API-only — it pulls the connected-firewalls list from Panorama, then for each firewall walks its candidate config and reports every XPath where the firewall-local value differs from what the template defines. Source: `utils/lib/OVERRIDEFINDER.php`.

**Verify:** every line printed is a fully-qualified XPath into `/config/devices/...` along with which value won.

**If it fails:** `override-finder` requires the Panorama API; it will refuse to run against an offline file. If you only have a Panorama XML, you cannot use this UTIL — you have to either temporarily get API access or use `type=diff` between the template and a snapshot of each firewall.

### 5.11 — Reduction: merge duplicate address objects across a Panorama hierarchy

**Goal:** collapse address objects that have identical values but different names so rules / groups / NAT rules all point at one canonical name.

**Prerequisites:** A snapshot of the Panorama config (`pan-os-php type=address in=api://panorama.local out=before.xml`).

**Steps:**

```bash
# Step 1 - DRY RUN. The merger treats actions=display as "compute the plan, write nothing"
pan-os-php type=address-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel \
  actions=display

# Step 2 - REAL RUN once the dry-run output looks correct
pan-os-php type=address-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel
```

`allowMergingWithUpperLevel` is the key argument: with it, an address object in a child device-group that has an identical-value sibling in `shared` (or any ancestor DG) is replaced by the upper-level one and deleted from the child. Without it, mergers run only inside each DG independently.

**Verify:**

```bash
pan-os-php type=address in=after.xml stats
pan-os-php type=xml-issue in=after.xml
```

The post-merge address count should be lower; `xml-issue` should report a clean tree.

**If it fails:** if you see `there is a conflict with an object of same name and type. Please use address-merger.php script with argument 'allowmergingwithupperlevel'`, you forgot the upper-level flag. If the merger candidate set looks wrong, see GitHub issues #778 and #779 (both closed) for tag-related edge cases — and update via `update_pan-os-php.sh` before reporting a new bug.

### 5.12 — Reduction: merge duplicate service objects

**Goal:** collapse services with identical port/protocol but different names. Same dry-run-then-commit pattern as 5.11.

**Steps:**

```bash
# DRY RUN
pan-os-php type=service-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel \
  DupAlgorithm=SamePorts \
  actions=display

# REAL RUN
pan-os-php type=service-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel \
  DupAlgorithm=SamePorts
```

`DupAlgorithm` selects how duplicates are detected. For services the supported values are `SamePorts` (port-and-protocol match — the default and most common) and `Whereused` (objects referenced from the same set of rules).

**Verify:** `pan-os-php type=service in=after.xml stats` shows fewer services; `pan-os-php type=xml-issue in=after.xml` is clean.

**If it fails:** see `unsupported value for dupAlgorithm: <name>` in section 6 — `DupAlgorithm` is case-sensitive on input.

### 5.13 — Reduction: merge duplicate tags

**Goal:** collapse tag duplicates (e.g. `Production` and `production` and `PROD`) into one canonical tag.

**Steps:**

```bash
# DRY RUN
pan-os-php type=tag-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel \
  actions=display

# REAL RUN
pan-os-php type=tag-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel
```

`tag-merger` rewrites every reference to the merged-away tag — on rules, on objects, on groups — to the surviving tag. If you have a curated tag taxonomy, run a `name-Replace-Character` pass (recipe 5.15) on the survivors first to enforce naming consistency, then merge.

**Verify:** `pan-os-php type=tag in=after.xml stats`; the unused-tag query (recipe 5.4) against `after.xml` returns the orphaned tags from the merge, ready for `actions=delete`.

**If it fails:** if a tag survives merging that you expected to disappear, check whether it has tag-on-tag references — colour assignments and parent-tag relationships — that a merger may treat as a pinning reference. Re-run with `actions=display` and read the per-tag plan output.

### 5.14 — Reduction: merge duplicate address groups and service groups

**Goal:** consolidate redundant groups whose membership lists are identical.

**Steps:**

```bash
# DRY RUN address groups
pan-os-php type=addressgroup-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel \
  DupAlgorithm=SameMembers \
  actions=display

# REAL RUN
pan-os-php type=addressgroup-merger \
  in=before.xml out=after.xml \
  location=any \
  allowMergingWithUpperLevel \
  DupAlgorithm=SameMembers
```

For address groups, `DupAlgorithm` supports `SameMembers` (literal member-list equality), `SameIP4Mapping` (groups whose resolved IP set is identical even when membership lists differ), and `Whereused` (groups referenced from the same rules). For service groups, the same pattern with `DupAlgorithm=SameMembers` or `SamePortMapping`.

**Verify:** before-and-after group counts; spot-check a few previously-duplicate groups in the GUI.

**If it fails:** see the open issue #798 for known address-group-merger edge cases — fall back to `addressgroup-merger` with a tighter `location=DG-Name` scope and run iteratively per device-group rather than `location=any` if the candidate set looks wrong.

### 5.15 — Reduction: bulk rename to normalize naming

**Goal:** apply a naming convention — prefix, suffix, character replacement, regex rewrite — across hundreds of objects in one pass.

**Steps:**

```bash
# Add a prefix to every object whose name starts with "dev-"
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name regex /^dev-/)' \
  actions=name-addPrefix:LAB-

# Strip an obsolete prefix
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name regex /^OLD-/)' \
  actions=name-removePrefix:OLD-

# Append a suffix
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(tag has Decommissioning)' \
  actions=name-addSuffix:_RETIRED

# Replace a forbidden character (spaces -> underscore)
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name regex / /)' \
  actions=name-Replace-Character:" ",_

# Direct rename of one specific object
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name eq h-10.0.0.5)' \
  actions=name-Rename:srv-prod-db
```

The `name-Replace-Character:<search>,<replace>` syntax accepts the helper aliases `$$comma$$`, `$$forwardslash$$`, `$$colon$$`, and `$$pipe$$` to escape characters that the shell or argument parser would otherwise eat.

**Verify:**

```bash
grep '<entry name="LAB-' after.xml | wc -l
```

should match the change count printed at the end of the run.

**If it fails:** the action is global within the matched filter set, so a wrong filter rewrites the wrong objects. Always pair the rename with `outputformatset=rename.set` so the equivalent CLI commands are emitted to a file you can review:

```bash
pan-os-php type=address \
  in=before.xml \
  'filter=(name regex /^dev-/)' \
  actions=name-addPrefix:LAB- \
  outputformatset=rename-preview.set
```

Note the closed issue #764: when renaming an address object, references inside *interface* configs (e.g. IKE gateway `peer-address` set to an address object name) were not always rewritten on older builds. After a rename, run `actions=display` against `type=interface` to confirm no orphan references remain.

### 5.16 — Reduction: quarantine-then-delete unused rules

**Goal:** retire dead security rules without deleting them on day one. Tag-and-disable now, audit for a watch period, delete only after no one complains.

**Phase 1 (today): tag and disable.**

```bash
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=(rule is.unused.fast)' \
  actions=tag-Add-Force:Quarantine-2026Q2/disabled-Set \
  outputformatset=quarantine.set
```

Review `quarantine.set` to make sure the rule list looks right, then re-run without `outputformatset` to push to the device. `tag-Add-Force` is used here instead of plain `tag-Add` so the tag is created on first reference if it does not already exist.

**Phase 2 (after the watch period — typically 30 days): delete.**

```bash
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=((tag has Quarantine-2026Q2) and (rule is.disabled))' \
  actions=display

# After confirming the displayed list is what you expect:
pan-os-php type=rule \
  in=api://10.0.0.5 \
  'filter=((tag has Quarantine-2026Q2) and (rule is.disabled))' \
  actions=delete
```

**Verify:** between phase 1 and phase 2, monitor the Monitor > Logs GUI for any traffic that gets dropped because of the disabled-but-still-named rules (PAN-OS will log "denied: implicit deny" at the end of the rulebase). Any such traffic means a rule was wrongly quarantined — re-enable just that rule.

**If it fails:** see closed issue #786 for the historical workaround — older builds required a tag (or zone) to already be present in the rulebase before bulk-add could reference it. If you hit that on an older install, update via `update_pan-os-php.sh`.

### 5.17 — Reduction: merge shadowed and adjacent security rules

**Goal:** find groups of security rules that match identical traffic ("matchFromToSrcDstApp" — same from-zones, to-zones, source, destination, app) and collapse them into one.

**Prerequisites:** API or saved Panorama config; decision on whether to merge across the entire rulebase (`mergeAdjacentOnly=no`) or only consecutive duplicates (`mergeAdjacentOnly=yes`).

**Steps:**

```bash
# DRY RUN - preview the merge plan
pan-os-php type=rule-merger \
  in=panorama.xml out=after.xml \
  location=any \
  panoramaPreRules \
  method=matchFromToSrcDstApp \
  actions=display

# REAL RUN
pan-os-php type=rule-merger \
  in=panorama.xml out=after.xml \
  location=any \
  panoramaPreRules \
  method=matchFromToSrcDstApp
```

The `method=` argument selects what counts as "the same rule". The supported values are:

| Method | Considers identical when these fields match |
|---|---|
| `matchFromToSrcDstApp` | from, to, source, destination, application |
| `matchFromToSrcDstSvc` | from, to, source, destination, service |
| `matchFromToSrcSvcApp` | from, to, source, service, application |
| `matchFromToDstSvcApp` | from, to, destination, service, application |
| `matchFromSrcDstSvcApp` | from, source, destination, service, application |
| `matchToSrcDstSvcApp` | to, source, destination, service, application |
| `matchToDstSvcApp` | to, destination, service, application |
| `matchFromSrcSvcApp` | from, source, service, application |
| `identical` | every rule field |

Optional refinements: `additionalMatch=tag,secprof,user,urlcategory,target,logsetting,logprof` to extend the equality check; `mergeAdjacentOnly=yes` to only collapse runs of consecutive duplicates; `mergeDenyRules=yes` to allow merging across deny rules (default no — deny-then-allow patterns matter); `stopMergingIfDenySeen=no` to keep going past the first deny.

**Verify:** before-and-after rule counts; for a Panorama, run a `type=diff in=panorama.xml in2=after.xml` to inspect every difference.

**If it fails:** `Panorama was detected but no Pre or Post rules were selected, use CLI argument 'panoramaPreRules' or 'panoramaPostRules'` means exactly that — pick one. See the open issue #572 for known cases where not all shadow rules get merged on a single pass; running the merger twice with different `method=` values often catches the rest.

### 5.18 — Reduction: move objects to a parent device-group to deduplicate across child DGs

**Goal:** if `addr-prod-db` lives identically in `DG-East`, `DG-West`, and `DG-Central`, move it to a parent DG (or `shared`) so all three children inherit one canonical copy.

**Steps:**

```bash
# DRY RUN
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name eq addr-prod-db)' \
  actions=display

# REAL RUN
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name eq addr-prod-db)' \
  actions=move:shared,skipIfConflict
```

The `move` action takes two arguments: a target location (`shared` or a DG/VSYS name) and a conflict-handling mode. The supported modes are `skipIfConflict` (don't move if an object of the same name already exists at the target — the safe default), `removeIfMatch` (move and silently remove the duplicate if its value also matches), and `removeIfNumericalMatch` (move and remove the duplicate if its numerical value is identical, even if the textual representation differs).

For a bulk move based on tag:

```bash
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(tag has CanonicalShared)' \
  actions=move:shared,skipIfConflict
```

**Verify:** in the Panorama GUI, the moved object now appears under `shared`; child DGs show the inherited object (greyed-out edit pencil).

**If it fails:** if the target location already has an object with the same name but different value, `skipIfConflict` simply leaves the source alone — no error, no move. Switch to `actions=display` first and look for the lines reading `there is a conflict with an object of same name and type`. If you genuinely want to overwrite, use `address-merger` with `allowMergingWithUpperLevel` instead (recipe 5.11) — that handles the conflict resolution properly.

### 5.19 — Reduction: replace, decommission, or delete a specific object

**Goal:** stop using an object, replacing references where possible, then remove it.

**Steps:**

```bash
# Replace every reference to OLD-DB with NEW-DB across the entire config
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name eq OLD-DB)' \
  actions=replaceWithObject:NEW-DB

# Or "decommission" (a soft-delete pattern that flags + renames the object for audit)
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name eq OLD-DB)' \
  actions=decommission

# Or hard delete after confirming zero references with displayReferences (recipe 5.7)
pan-os-php type=address \
  in=before.xml out=after.xml \
  'filter=(name eq OLD-DB)' \
  actions=delete
```

`actions=delete` refuses to delete an object that still has references; that is deliberate. `actions=delete-Force` overrides the safety check — only use it when you have already audited every reference with `displayReferences`. `actions=removeWhereUsed` walks every rule and group, strips the object from each member list, and *then* deletes — useful when the object's "real" delete blocks because of a stale group.

**Verify:** after `replaceWithObject`, `pan-os-php type=address in=after.xml 'filter=(name eq OLD-DB)' actions=displayReferences` should print zero references.

**If it fails:** see the closed issue #771 if `removeWhereUsed:delete` returns an error — update via `update_pan-os-php.sh`. If `delete` complains about references that the GUI says don't exist, run `pan-os-php type=xml-issue in=before.xml` first; orphaned XML nodes can show up as references the renderer doesn't display.

---

## 6. Troubleshooting

Each entry uses **Error / Cause / Solution / Prevention** format. Real GitHub issue numbers are referenced where they fit.

### Error: `*** ERROR **** PAN-OS-PHP requires PHP version >= 5.5`

**Cause:** The framework's first runtime check refuses PHP 5.4 or older.

**Solution:** Install PHP 7.4+ via your distro's package manager (`apt install php8.1`, `yum install php74`, or use the Remi repo on CentOS), or skip the host PHP entirely with the Docker image.

**Prevention:** Use the bundled install scripts (`install_script_ubuntu22.sh`, `install_script_centos7.sh`) — they install the supported PHP version automatically.

### Error: `unsupported argument provided: <name>`

**Cause:** You passed an `argument=` that is not in the UTIL's `supportedArguments` table. Common typos: `inn=` instead of `in=`, `action=` (singular) instead of `actions=`. Source: `utils/lib/UTIL.php:640`, `utils/develop/ike.php:106`, `utils/develop/wildcard_calculator.php:71`.

**Solution:** Run `pan-os-php type=<X> help` to see the exact argument list.

**Prevention:** Enable bash completion (`set_alias_usage.sh` does this for you) — Tab will only offer real argument names.

### Error: `unsupported Action: <name>`

**Cause:** The action name is misspelled, or the action exists for a different `type=`. Action names are case-sensitive. Source: `utils/lib/UTIL.php:1171`.

**Solution:** `pan-os-php type=<X> listactions` lists every action and its argument schema.

**Prevention:** Look the action up in `utils/lib/util_action_filter.json` (regenerate with `type=util_get-action-filter`).

### Error: `argument actions only support value: merge / display / mergenoghost`

**Cause:** You passed an action other than `merge`, `display`, or `mergenoghost` to a merger UTIL (`address-merger`, `service-merger`, `tag-merger`, etc). Mergers do not accept the standard object-action vocabulary.

**Solution:** Use `actions=display` to dry-run, `actions=merge` (or no `actions` at all — that is the default) for the real run, or `actions=mergenoghost` to skip the ghost-mode safety pass.

### Error: `Could not connect to LDAP server.`

**Cause:** The `user-check-ldap` action needs a reachable LDAP server defined in the input config. Source: `utils/common/actions-rule.php:5137`.

**Solution:** Verify connectivity from your jump host (`ldapsearch -H ldap://...`) and that the Panorama config has an LDAP server profile referenced from the rule's source-user list.

**Prevention:** Run `actions=display` first to preview which rules would have their users validated; address any missing LDAP-server-profile entries before retrying.

### Error: `Panorama was detected but no Pre or Post rules were selected, use CLI argument`

**Cause:** Source: `utils/lib/RULEMERGER.php:839`. When loading a Panorama config you must scope rule operations to either pre-rulebase or post-rulebase. Both at once is also rejected.

**Solution:** Add **exactly one** of `panoramaPreRules` or `panoramaPostRules` to the command.

**Prevention:** Save the chosen direction into your playbook so the same scope is used every run.

### Error: `cannot find vsys/device-group <name>`

**Cause:** `location=` references a name that does not exist in the loaded config. Source: `utils/develop/grp-static-to-dynamic.php:573`.

**Solution:** Run `pan-os-php type=device in=... actions=display` first to list the actual VSYS/DG names; copy the exact name (case-sensitive) into `location=`.

### Error: `Bug-08062022-2 "create-address-from-file"` with `force group = yes` and `rewrite = yes`

**Cause:** Reproducible glitch in the `create-address-from-file` action — see GitHub issue #588.

**Solution:** Avoid `force group = yes` together with `rewrite = yes` in the same invocation; split into two passes.

### Error: `removeWhereUsed:delete failing` / `description-append is failing`

**Cause:** Known bugs in specific actions, see GitHub issues #771 and #772 (both closed).

**Solution:** Update to the current `main` (`bash update_pan-os-php.sh`) — both fixes have landed since report.

### Error: interfaces using address-objects do not get updated after an address-object rename

**Cause:** GitHub issue #764 (closed). When renaming an address object via `name-Rename`, references inside interface configurations (e.g., IKE gateway peer addresses, interface IP overrides) were not always rewritten.

**Solution:** After the rename, run `actions=display` with a filter that scans both rules and interfaces; manually patch any interfaces still pointing at the old name. The `replace-IP-by-MT-like-Object` action is a more robust alternative for some cases.

### Error: New zone can't be added to rules unless it already exists in a rule

**Cause:** GitHub issue #786 (closed) — the zone has to be present somewhere in the rulebase before `from-Add`/`to-Add` accepts it.

**Solution:** Update via `update_pan-os-php.sh`, or manually create a throwaway rule that already references the new zone before re-running the bulk add. The `*-Add-Force` family of actions also bypasses the existence check.

### Error: `is.partially.or.fully.included.in.list` filter does not work as expected

**Cause:** GitHub issue #793 (closed) — Bug-11122024-1.

**Solution:** Update to current `main`. If still reproducible, fall back to `is.partially.included.in.list` and `is.fully.included.in.list` separately and combine with `or`.

### Error: Address Unused Recursive Filter inconsistencies

**Cause:** GitHub issue #791 (open) — `is.unused.recursive` was over- or under-counting in some hierarchical-DG scenarios.

**Solution:** Use both `is.unused` and `is.unused.recursive` and compare results; manually validate flagged objects against the GUI before deletion. Track issue #791 for status.

### Error: `error when running addressgroup-merger`

**Cause:** GitHub issue #798 (open). Edge cases in tag matching during address-group merging.

**Solution:** Run with `actions=display` and inspect which groups would be merged; if the candidate set looks wrong, fall back to manual merging via repeated `address-merger` runs scoped by tag.

### Error: `outputformatset doesn't take into account multi-vsys firewall command format`

**Cause:** GitHub issue #677 (closed). Set-format command output did not always include the `set vsys vsysN` prefix on multi-vsys firewalls.

**Solution:** Update via `update_pan-os-php.sh`.

### Error: PHP `Warning: include(...): failed to open stream`

**Cause:** PHP cannot find framework files because `include_path` is not set or because the working directory is not the install dir.

**Solution:** `php -i | grep include_path` — if the install path is missing, append it to the right `php.ini` (`/etc/php/8.1/cli/php.ini` on Ubuntu 22.04).

**Prevention:** Always invoke via the `pan-os-php` alias rather than running `php utils/pan-os-php.php` from arbitrary directories.

### Error: `Unable to write to file!`

**Cause:** From `utils/lib/logWriter.php:75`, `utils/lib/SOFTWARE_DOWNLOAD.php:1132`, `utils/lib/LICENSE__.php:450`, and the migration helpers (`utils/develop/migration/bluecoat.php:116`, etc.) — the process cannot write the output. Usually a permission issue or a missing parent directory.

**Solution:** Check write permission on the target directory (`projectfolder=` location, current directory if `out=` is relative). Create it if missing.

> [!CAUTION]
> Do not run `pan-os-php` as root unless required — many UTILs write into the current working directory by default, and root-owned outputs are a recurring support headache. Run as your own user, with `projectfolder=` pointing somewhere you own.

### Error: invalid JSON in `util_action_filter.json`

**Cause:** The file is regenerated by `type=util_get-action-filter`. If a previous run was interrupted, the file may be truncated.

**Solution:** Delete `utils/lib/util_action_filter.json` and re-run `pan-os-php type=util_get-action-filter`.

### Error: `Running PAN-OS-PHP on Panorama File (no API)`

**Cause:** GitHub issue #792 (open). Running purely against a Panorama-exported XML file (no API) hits edge cases in some UTILs that assume API access.

**Solution:** For affected UTILs, snapshot the device's current running config to disk via `in=api://... out=snap.xml`, then iterate offline against `snap.xml`. Track issue #792 for full no-API support.

### Error: `unsupported value for dupAlgorithm: <name>`

**Cause:** Source: `utils/lib/MERGER.php:628`. `DupAlgorithm=` was passed an algorithm name the merger does not implement. The supported values per merger type are `SamePorts` / `Whereused` (service-merger), `SameMembers` / `SameIP4Mapping` / `Whereused` (addressgroup-merger), `SameMembers` / `SamePortMapping` / `Whereused` (servicegroup-merger).

**Solution:** Run `type=address-merger help` (or any other merger) to list valid algorithms, or simply omit `DupAlgorithm` and let the merger pick.

### Diagnostic decision tree

When something fails, work down this list:

1. **Did the framework even load?** Run `pan-os-php version` — if the version line prints, PHP and the autoloader are fine; the problem is in your invocation.
2. **Is the right type?** Run `pan-os-php type=<your-type> help`. If you get usage rather than your error, the framework is happy with the type.
3. **Is the filter valid?** Replace `actions=...` with `actions=display`. If it still fails, the filter or input is at fault.
4. **Is the action valid?** Run `pan-os-php type=<your-type> listactions` and confirm the spelling and argument count.
5. **API issues?** Append `debugapi` and `shadow-displaycurlrequest` to see exactly what is being called.
6. **Memory / timeouts?** `debugloadtime` shows where time is spent; the framework already raises `memory_limit` to 14 GB but very large Panoramas can still exceed it.

---

## 7. FAQ

**Q: What versions of PAN-OS does pan-os-php support?**

The bundled IronSkillet templates cover PAN-OS 8.1, 9.1, 10.0, 10.1, 10.2, and 11.0, and the framework parses configurations from those versions out of the box. The XML schema is validated against `lib/resources/` schema files copied from the official Palo Alto product. If you load a config from a newer PAN-OS than the framework knows, you will see warnings like `*** Unsupported PANOS version: ...` from `utils/develop/grp-static-to-dynamic.php` (and similar) — update via `update_pan-os-php.sh`.

**Q: Does pan-os-php support Strata Cloud Manager / SASE?**

Partially. There is a `PanSaseAPIConnector` and a `sase-api://` URL form, but the framework explicitly errors with `PAN-OS-PHP connection method 'sase-api://' - do not yet support this UTIL type` for many UTILs. Check `key-manager` and the SASE-specific operators before relying on it.

**Q: How do I avoid passing the API key on the command line?**

Run any UTIL with `in=api://<host>` interactively the first time. The framework prompts for username/password, requests an API key from PAN-OS, and writes it to `~/.panconfkeystore`. Subsequent runs reuse the key. Disable persistence with `shadow-apikeynosave`.

**Q: Where do I configure the API timeout?**

`apitimeout=<seconds>` per invocation. Default is 60 seconds. Long-running operations like `bpa-generator cycleconnectedfirewalls` against a Panorama with many devices may need 300–600.

**Q: Why does my Panorama config require `location=`?**

By default the framework runs against `shared` for Panorama and `vsys1` for firewalls. To act on a specific device-group, set `location=<DGname>`; to act everywhere, `location=any`; to include child device-groups, `location=DGname:includechilddgs`; to act only on the immediate children, `location=DGname:excludeMaindg`.

**Q: Can I write my own action?**

Yes. Drop a PHP file under `utils/common/` that extends the relevant `*CallContext` (e.g., `RuleCallContext`) and adds an entry to its `$supportedActions` array. Load it at runtime with `loadplugin=path/to/myplugin.php`. The example `examples/example_plugin_rule_display_source_destination.php` shows the pattern.

**Q: Does `outputformatset=` cover every action?**

No — some destructive or vendor-specific actions emit no `set` command equivalent (the change is purely API-driven). For those, the file will simply not include a line for that action; the human report still describes what happened.

**Q: How do I handle a malformed input config?**

`pan-os-php type=xml-issue in=fw.xml` walks the tree and reports structural problems — empty entries, missing required attributes, duplicate names. Fix those first; most UTILs will refuse to operate on a config that fails this check.

**Q: How can I make the output JSON-friendly for piping into another tool?**

Append `shadow-json` to any UTIL invocation. The output goes from plain text + tables to a single JSON document on stdout. This is documented as BETA inside `UTIL.php`; adopt it for tooling but expect occasional schema changes.

**Q: Can I run pan-os-php in CI?**

Yes — the Docker image `swaschkut/pan-os-php:latest` is the simplest path. Mount your repo at `/share`, point `in=` at `/share/<config>.xml`, and write outputs to `/share/out/`. Use `shadow-json` for machine-readable results.

**Q: What is `iron-skillet/`?**

Vendored copies of Palo Alto's IronSkillet day-1 hardening templates — one folder per major PAN-OS version — used by the `ironskillet-update` UTIL. You normally do not need to touch them directly.

**Q: What is `migration/parser/<vendor>/`?**

Per-vendor parsers used by `type=vendor-migration` to convert non-Palo configurations (Cisco ASA, Cisco ISR, Cisco Switch, NetScreen, SonicWall, Sophos, Fortinet, Juniper SRX, CheckPoint R80, Huawei, StoneSoft, Sidewinder, pfSense) into PAN-OS XML. Each parser is a `PARSER` subclass that extends `CONVERTER` (which extends `UTIL`).

**Q: What is `appid-toolbox/`?**

A self-contained sub-toolset (with its own `lib/`, `tests/`, and `report-generator.php` / `rule-cleaner.php` / `rule-cloner.php` / `rule-marker.php` / `rule-activation.php` entry scripts) for the multi-week App-ID activation workflow. The companion UTIL `type=appid-toolbox` drives it from `pan-os-php` itself.

**Q: How do I report a bug?**

Open an issue at <https://github.com/PaloAltoNetworks/pan-os-php/issues>. Include your `pan-os-php version` output, the exact command line, a redacted snippet of the input XML around the affected element, and the full error stack (the framework prints a backtrace on `derr()`). See `SUPPORT.md` in the repo for the official policy: pan-os-php is community-supported and not covered by Palo Alto Networks TAC.

**Q: How do I retire pan-os-php cleanly?**

`bash remove_path_variable.sh` undoes the `php.ini` and shell rc changes; `rm -rf /tools/pan-os-php` removes the source; `rm ~/.panconfkeystore` removes cached keys.

**Q: What does the version number `2.1.20` mean?**

Source `lib/misc-classes/PH.php` defines `library_version_major=2`, `library_version_sub=1`, `library_version_bugfix=20`. Major bumps reflect breaking framework changes (new class hierarchies, changed action signatures); sub bumps add new UTILs/actions; bugfix bumps fix specific reported bugs. Pin a major version in CI; expect new actions inside a sub series.

---

_Generated April 2026 from PaloAltoNetworks/pan-os-php at d87de87. AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it. Unofficial, AI-generated guide for a Palo Alto Networks open-source repository; not reviewed by Palo Alto Networks._
