pan-os-php User Guide

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.

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

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
***********************************************
*********** 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.

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.

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

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

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:

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:

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)

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:

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:

php -r "require('/tools/pan-os-php/lib/pan_php_framework.php');print 'PAN-OS-PHP LIBRARY - OK INSTALL SUCCESSFUL\n';"
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

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

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:

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

Then check the dispatcher itself:

pan-os-php version
 - 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:

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

Updating

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 /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:

# 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:

# 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:

pan-os-php type=rule listactions
* delete                     |                          |              |
==================================================================================================
* tag-Add                    |  #1 tagName:string       | <empty>      |
==================================================================================================
* tag-Add-Force              |  #1 tagName:string       | <empty>      |
==================================================================================================
* enabled-Set                |                          |              |
==================================================================================================
* disabled-Set               |                          |              |
... [hundreds more rows]
pan-os-php type=rule listfilters
* 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:

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:

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:

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:

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:

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

For Panorama:

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:

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:

# 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:

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:

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:

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:

# 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:

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:

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:

# 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:

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:

# 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:

# 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:

# 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:

# 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:

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:

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.

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.

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:

# 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:

# 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:

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:

# 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.